diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index 5b7c03cdf..cbfde3e3d 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -4,27 +4,27 @@ on: push: branches: [main] paths: - - 'services/agents-api/**' + - 'services/core/**' - 'contracts/agents-api/**' - 'packages/agents-client/**' - 'internal/**' - 'go.mod' - 'go.sum' - 'Makefile' - - 'scripts/build-agents-api.sh' - - 'scripts/build-agents-api-image.sh' + - 'scripts/build-core.sh' + - 'scripts/build-core-image.sh' - '.github/workflows/api-acceptance.yml' pull_request: paths: - - 'services/agents-api/**' + - 'services/core/**' - 'contracts/agents-api/**' - 'packages/agents-client/**' - 'internal/**' - 'go.mod' - 'go.sum' - 'Makefile' - - 'scripts/build-agents-api.sh' - - 'scripts/build-agents-api-image.sh' + - 'scripts/build-core.sh' + - 'scripts/build-core-image.sh' - '.github/workflows/api-acceptance.yml' permissions: @@ -62,7 +62,7 @@ jobs: env: OAC_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable run: | - OAC_DEV_CORE_BUILD_DIR="$RUNNER_TEMP/oac-core-build" make build-agents-api + OAC_DEV_CORE_BUILD_DIR="$RUNNER_TEMP/oac-core-build" make build-core OAC_DATABASE_URL="$OAC_TEST_DATABASE_URL" "$RUNNER_TEMP/oac-core-build/oac-core-migrate" "$RUNNER_TEMP/oac-core-build/oac-core-device" --help "$RUNNER_TEMP/oac-core-build/oac-core-environment-key" --help @@ -74,7 +74,7 @@ jobs: python - <<'PY' import json, subprocess, sys pin = json.load(open('contracts/agents-api/upstream.json')) - subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', 'services/agents-api/tests/requirements.txt', + subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', 'services/core/tests/requirements.txt', 'openai @ git+https://github.com/openai/openai-python@' + pin['commit']], check=True) PY - name: Verify official-client HTTP compatibility @@ -83,11 +83,11 @@ jobs: OAC_TEST_SERVER_BIN: ${{ runner.temp }}/oac-core-build/oac-core OAC_TEST_OFFICIAL_SDK_PYTHON: python run: | - python services/agents-api/tests/official_client.py - go test ./services/agents-api/internal/store -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient|TestSelfHostedFunctionsOfficialClient|TestSelfHostedSteeringOfficialClient)$' -count=1 + python services/core/tests/official_client.py + go test ./services/core/internal/store -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient|TestSelfHostedFunctionsOfficialClient|TestSelfHostedSteeringOfficialClient)$' -count=1 - name: Verify standalone container distribution env: OAC_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable OAC_DEV_CORE_IMAGE: oac-core:ci OAC_TEST_OFFICIAL_SDK_PYTHON: python - run: make check-agents-api-container + run: make check-core-container diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 39e6594ab..f91b4e11a 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -3,7 +3,7 @@ name: native-check on: pull_request: paths: - - 'apps/parsar-daemon/**' + - 'apps/daemon/**' - 'internal/**' - 'contracts/agents-api/**' - 'packages/claude-sdk-adapter/**' @@ -12,7 +12,7 @@ on: - 'scripts/build-native-installer*' - 'scripts/native-harness-smoke.mjs' - 'scripts/native-onboarding-smoke.mjs' - - 'services/agents-api/internal/nativeinstaller/**' + - 'services/core/internal/nativeinstaller/**' - 'scripts/build-mcode-harness.sh' - 'go.mod' - 'go.sum' @@ -61,42 +61,42 @@ jobs: node-version: '22.22.0' - name: Build the native daemon and test bundle boundaries run: | - go build -ldflags "-X github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + go build -ldflags "-X github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/daemon/cmd/oac-daemon node --test scripts/build-native-installer.test.mjs - name: Native filesystem, authentication and process lifecycle run: >- go test -race -count=1 ./internal/runtimefs - ./apps/parsar-daemon/internal/auth - ./apps/parsar-daemon/internal/paths - ./apps/parsar-daemon/internal/daemonize - ./apps/parsar-daemon/internal/agent/clirunner + ./apps/daemon/internal/auth + ./apps/daemon/internal/paths + ./apps/daemon/internal/daemonize + ./apps/daemon/internal/agent/clirunner - name: Native Harness ownership and environment identity run: >- go test -race -count=1 - ./apps/parsar-daemon/internal/agent/codex - ./apps/parsar-daemon/internal/agent/claudesdk - ./apps/parsar-daemon/internal/agent/mcode + ./apps/daemon/internal/agent/codex + ./apps/daemon/internal/agent/claudesdk + ./apps/daemon/internal/agent/mcode -run 'TestJSONRPCClientOwnsToolDescendants|TestSelfHostedToolEnvironment' - name: Native capability snapshots, files and installation run: >- go test -race -count=1 - ./apps/parsar-daemon/internal/localworkspace - ./apps/parsar-daemon/internal/cli + ./apps/daemon/internal/localworkspace + ./apps/daemon/internal/cli -run 'TestNative|TestRuntimeInit|TestRuntimePreparationRejects|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' - name: Windows npm and npx stdio launchers without network if: runner.os == 'Windows' run: >- go test -race -count=1 -timeout=2m - ./apps/parsar-daemon/internal/localworkspace - ./apps/parsar-daemon/internal/cli + ./apps/daemon/internal/localworkspace + ./apps/daemon/internal/cli -run 'TestWindowsPackageManager|TestWindowsRuntimeMCP' - name: Build pinned native components run: | npm install --global pnpm@10.30.3 - pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... - pnpm --filter @parsar/claude-sdk-adapter build --outDir "$RUNNER_TEMP/claude-compiled" - pnpm --config.inject-workspace-packages=true --config.extend-node-path=false --filter @parsar/claude-sdk-adapter deploy --prod "$RUNNER_TEMP/claude-runtime" + pnpm install --frozen-lockfile --filter @oac/claude-sdk-adapter... + pnpm --filter @oac/claude-sdk-adapter build --outDir "$RUNNER_TEMP/claude-compiled" + pnpm --config.inject-workspace-packages=true --config.extend-node-path=false --filter @oac/claude-sdk-adapter deploy --prod "$RUNNER_TEMP/claude-runtime" rm -rf "$RUNNER_TEMP/claude-runtime/dist" mv "$RUNNER_TEMP/claude-compiled" "$RUNNER_TEMP/claude-runtime/dist" # Reify the dedicated frozen export lock without pnpm's symlink layout. diff --git a/.gitignore b/.gitignore index c8059bfec..2b3279650 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,8 @@ __pycache__/ /state/ /cache/ /target/ -# `go build ./services/core-console` in the repository root writes this binary. -/core-console +# `go build ./services/web` in the repository root writes this binary. +/web /playwright-report/ /test-results/ /.agents/ diff --git a/AGENTS.md b/AGENTS.md index 151e790de..acd8b64d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,14 +15,14 @@ OpenAgentCore is protocol-first and modular. Core orchestrates operations that p | Boundary | Protocol code | Protocol doc | | --- | --- | --- | -| Application–Core (`/v1`) | Types in `contracts/agents-api/v1/` and route annotations in `services/agents-api/internal/api/`; `make openapi` generates `contracts/agents-api/openapi.yaml` | [Agents API guide](docs/api/public-agent-api.md) | -| Web and operators–Core (`/core/v1`) | Route annotations in `services/agents-api/internal/api/`; `make openapi` generates `contracts/agents-api/core.openapi.yaml` | [Core API](docs/api/README.md#core-api) | -| Nodes and daemons–Core (`/api/v1` HTTP routes; the node and daemon wire protocols are separate rows) | Route annotations in `services/agents-api/internal/api/`; `make openapi` generates `contracts/agents-api/runtime.openapi.yaml` | [Machine connection API](docs/api/README.md#machine-connection-api) | -| Core–Sandbox Provider | `services/agents-api/internal/sandbox/sandbox_provider.go` | [Sandbox Provider guide](docs/sandbox-provider.md) | -| Core–sandbox node | `services/agents-api/internal/sandbox/node/wire.go` | [Node generation protocol](contracts/agents-api/node-generation-protocol.md) | +| Application–Core (`/v1`) | Types in `contracts/agents-api/v1/` and route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/openapi.yaml` | [Agents API guide](docs/api/public-agent-api.md) | +| Web and operators–Core (`/core/v1`) | Route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/core.openapi.yaml` | [Core API](docs/api/README.md#core-api) | +| 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](docs/api/README.md#machine-connection-api) | +| Core–Sandbox Provider | `services/core/internal/sandbox/sandbox_provider.go` | [Sandbox Provider guide](docs/sandbox-provider.md) | +| Core–sandbox node | `services/core/internal/sandbox/node/wire.go` | [Node generation protocol](contracts/agents-api/node-generation-protocol.md) | | Provider–Runtime startup | `internal/runtimebootstrap/bootstrap.go` | [Runtime bootstrap](docs/runtime-bootstrap.md) | | Core–Runtime wire | `internal/agentdaemon/proto/` | [Core–Runtime protocol](docs/runtime-protocol.md) | -| Runtime–Harness | `apps/parsar-daemon/internal/agent/harness.go` | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | +| Runtime–Harness | `apps/daemon/internal/agent/harness.go` | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | | Harness–Model provider | `internal/modelprovider/config.go` | [Model execution](contracts/agents-api/model-execution.md) | Most boundaries still span several files; the listed file or directory is the entry point. Do not add files to a boundary; this is a [known gap](#known-gaps). @@ -57,10 +57,10 @@ OpenAgentCore is pre-release. Replace superseded interfaces, execution paths and Existing code still breaks these rules in places. The bullets below are examples, not a complete list. Do not copy these patterns. Until a gap is closed, follow the extension guide; a change that touches a gap moves it toward the rule. -- Protocol definitions spread over several files, such as the Sandbox Provider contract across `services/agents-api/internal/sandbox/` and `services/agents-api/internal/providercontract/`. -- Support discovered by type assertion, such as daemon workspace reads in `apps/parsar-daemon/internal/dispatch/workspace_read.go` and Core's observation source selection in `services/agents-api/cmd/server/main.go`. -- Harness-specific code in shared places, such as Core engine profiles in `services/agents-api/internal/engine/.go`, daemon discovery and registration, the installer's Harness list and default in `deploy/install/config.schema.json`, and Core's own default Harness when `OAC_DEFAULT_HARNESS` is unset. -- Vendor-specific configuration, routes and UI outside the adapter, such as the E2B selection and store fields (`services/agents-api/internal/sandbox/selection.go`), the `/core/v1/sandbox/e2b/*` routes, E2B credential hooks in `providers.Adapter` and the E2B Web views. +- Protocol definitions spread over several files, such as the Sandbox Provider contract across `services/core/internal/sandbox/` and `services/core/internal/providercontract/`. +- Support discovered by type assertion, such as daemon workspace reads in `apps/daemon/internal/dispatch/workspace_read.go` and Core's observation source selection in `services/core/cmd/server/main.go`. +- Harness-specific code in shared places, such as Core engine profiles in `services/core/internal/engine/.go`, daemon discovery and registration, the installer's Harness list and default in `deploy/install/config.schema.json`, and Core's own default Harness when `OAC_DEFAULT_HARNESS` is unset. +- Vendor-specific configuration, routes and UI outside the adapter, such as the E2B selection and store fields (`services/core/internal/sandbox/selection.go`), the `/core/v1/sandbox/e2b/*` routes, E2B credential hooks in `providers.Adapter` and the E2B Web views. - Host-local state spread over several `~/.oac/` directories, such as the Runtime's `~/.oac/daemon/`, `~/.oac/runtime//` and `~/.oac/environments//`. - Persistence and vendor types in the Core and machine OpenAPI documents, such as the `store.*` and `e2b.*` definitions in `contracts/agents-api/core.openapi.yaml`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57d1bd581..2cd9664df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,16 +13,16 @@ This guide owns how to work in the repository: documentation ownership, the repo | Developer setup, repository map and focused checks | [Develop OpenAgentCore](docs/development.md) | | API callers, credentials and route inventory | [API index](docs/api/README.md) | | Public wire types and qualified behavior | [Agents API contracts](contracts/agents-api/README.md), [pinned upstream](contracts/agents-api/upstream.json), and linked operation contracts | -| Core service implementation constraints | [Agents API implementation constraints](services/agents-api/IMPLEMENTATION.md) and [service README](services/agents-api/README.md) | +| Core service implementation constraints | [Agents API implementation constraints](services/core/IMPLEMENTATION.md) and [service README](services/core/README.md) | | Environment ownership and capability preparation (Skills, Plugins, MCP, `packages.system`) | [Environments](contracts/agents-api/environments.md) | | Built-in Harness identifiers, configuration/profile bindings and display names | `internal/harnessconfig/builtin/catalog.json` and its [generated reference](contracts/agents-api/harness-catalog.md) | -| Effective MCP bindings and credential authority | [Environment MCP](contracts/agents-api/environments.md#skills-plugins-and-environment-mcp) and `apps/parsar-daemon/internal/agent/mcp_binding.go` | +| Effective MCP bindings and credential authority | [Environment MCP](contracts/agents-api/environments.md#skills-plugins-and-environment-mcp) and `apps/daemon/internal/agent/mcp_binding.go` | | Harness qualification and acceptance | [Harness integration](contracts/agents-api/harnesses.md) | | Harness selection and Agent defaults | [Harness selection](contracts/agents-api/harness-selection.md) | | Provider selection, sandbox deployment and E2B setup | [Sandbox deployment](contracts/agents-api/sandbox-deployment.md) | | Hosted sandbox nodes | [Nodes guide](docs/getting-started/nodes.md) and [sandbox deployment contract](contracts/agents-api/sandbox-deployment.md) | | Claude private bridge and Runtime artifact | [Claude SDK adapter](packages/claude-sdk-adapter/README.md) | -| MiniMax Code and Claude Runtime adapter rules | [MiniMax Code Runtime](services/agents-api/deploy/mcode/README.md), [Claude Runtime](services/agents-api/deploy/claude/README.md) | +| MiniMax Code and Claude Runtime adapter rules | [MiniMax Code Runtime](services/core/deploy/mcode/README.md), [Claude Runtime](services/core/deploy/claude/README.md) | | CI, distribution builds, installer lifecycle and managed HTTPS, release publication | [Maintainer guide](docs/maintainers.md) | | Operator installation, installation layout and configuration | [Installation](docs/getting-started/install.md), [installation options](docs/getting-started/install-options.md), [configuration](docs/configuration.md), [operations](docs/getting-started/operations.md) | | Core Web console server and sign-in | [Console server](docs/web/console-server.md) | @@ -35,7 +35,7 @@ This repository is the standalone execution substrate copied from Parsar at the Product users, workspaces, model catalogs, business assets, the Parsar product Web, product API and product migrations remain in Parsar. Do not import `server/`, `apps/parsar/`, product CLI/plugin packages or their deployment stack. -Preserve copied Runtime and protocol behavior. Existing Go import paths stay unchanged and do not require fetching the original repository. The source snapshot and per-file hashes are an audit trail; future Core development need not preserve them. Do not automatically sync or delete the original repository's Core. +Preserve copied Runtime and protocol behavior. Go import paths use this repository's module and do not require fetching the original repository. The source snapshot and per-file hashes are an audit trail; future Core development need not preserve them. Do not automatically sync or delete the original repository's Core. ### Product and execution service separation @@ -120,8 +120,8 @@ The role needs `CREATE DATABASE`: managed-provider tests create and drop isolate - `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. -- `make sqlc-generate` owns only `services/agents-api/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. -- `make check-runtime-contract` is the focused Core–Runtime contract entry point; see [Contract verification](docs/runtime-protocol.md#contract-verification). It also runs through `check-go` and `check-agents-api`. +- `make sqlc-generate` owns only `services/core/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. +- `make check-runtime-contract` is the focused Core–Runtime contract entry point; see [Contract verification](docs/runtime-protocol.md#contract-verification). It also runs through `check-go` and `check-core`. ### Compatibility evidence @@ -158,7 +158,7 @@ Build the MiniMax companion from this revision's pinned patched native sources. ## Branding -Public project branding uses OpenAgentCore. The canonical vector mark is `docs/assets/openagentcore-logo.svg`; Core Web, docs and landing-page assets use the same outline, with transparent margins cropped, theme-aware favicon colors and dark-surface inversion. The canonical SVG preserves the reference PNG canvas. The README hero uses the supplied `docs/assets/openagentcore-banner.jpeg`. The `example/parsar/` workbench retains its own name, logo and favicon. Historical provenance, external repository URLs, import paths and existing data identifiers retain their original spelling; do not rename those as display copy. +Public project branding uses OpenAgentCore. The canonical vector mark is `docs/assets/openagentcore-logo.svg`; Core Web, docs and landing-page assets use the same outline, with transparent margins cropped, theme-aware favicon colors and dark-surface inversion. The canonical SVG preserves the reference PNG canvas. The README hero uses the supplied `docs/assets/openagentcore-banner.jpeg`. The `example/parsar/` workbench retains its own name, logo and favicon. Historical provenance, external repository URLs and existing data identifiers retain their original spelling; do not rename those as display copy. ## OpenAgentCore name guard @@ -169,7 +169,6 @@ Public project branding uses OpenAgentCore. The canonical vector mark is `docs/a These identities stay unchanged: -- Go module and source directory paths, npm and Cargo package identities; - public `AgentCoreError`, upstream contract fields and the separate Parsar product; - persisted credential encryption domains and native-session resume keys, so existing data can be decrypted and Sessions can resume. diff --git a/Makefile b/Makefile index 28faf0b73..0fd6cef42 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,12 @@ 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-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-core build-core-release check-core docker-build-core check-core-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: - @printf '%s\n' 'make build-agents-api 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.' + @printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' -check: check-harness-catalog check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-example check-mcode-harness +check: check-harness-catalog check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-core check-claude-sdk check-web check-example check-mcode-harness @printf 'OpenAgentCore checks passed.\n' .PHONY: generate-harness-catalog check-harness-catalog @@ -31,7 +31,7 @@ check-database: @test -n "$${OAC_TEST_DATABASE_URL:-}" || { echo 'Set OAC_TEST_DATABASE_URL to a dedicated test PostgreSQL database' >&2; exit 1; } sqlc-generate: - cd services/agents-api && $(SQLC) generate + cd services/core && $(SQLC) generate SWAG ?= go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) @@ -40,7 +40,7 @@ openapi: @set -e; root="$${OAC_DEV_HOME:-$$HOME/.oac}/build"; mkdir -p "$$root"; \ output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ $(SWAG) init \ - -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ + -g cmd/server/main.go --dir ./services/core,./contracts/agents-api/v1 \ --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ @@ -50,50 +50,50 @@ check-sqlc: python3 scripts/check-sqlc.py check-go: - go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 + go test ./apps/daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 .PHONY: check-runtime-contract check-runtime-contract: - go test ./internal/agentdaemon/proto ./internal/agentdaemon/gateway ./apps/parsar-daemon/internal/transport ./apps/parsar-daemon/internal/dispatch ./apps/parsar-daemon/internal/contracttest -count=1 - go test ./services/agents-api/internal/execution -run '^TestRuntimeProtocol' -count=1 - go test ./apps/parsar-daemon/internal/agent/... -run '^(TestSharedTextLifecycle|TestPublicHarnessContractDeclarations|TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement|TestUnsupportedExtensionsHaveNoNativeEffects)$$' -count=1 + go test ./internal/agentdaemon/proto ./internal/agentdaemon/gateway ./apps/daemon/internal/transport ./apps/daemon/internal/dispatch ./apps/daemon/internal/contracttest -count=1 + go test ./services/core/internal/execution -run '^TestRuntimeProtocol' -count=1 + go test ./apps/daemon/internal/agent/... -run '^(TestSharedTextLifecycle|TestPublicHarnessContractDeclarations|TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement|TestUnsupportedExtensionsHaveNoNativeEffects)$$' -count=1 build-daemon: @set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/daemon"; \ [[ "$$output" == /* ]] || { echo 'Daemon output directory must be absolute' >&2; exit 1; }; \ mkdir -p "$$output"; \ - CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$$output/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon + CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$$output/oac-daemon" ./apps/daemon/cmd/oac-daemon -build-agents-api: - ./scripts/build-agents-api.sh +build-core: + ./scripts/build-core.sh -build-agents-api-release: - ./scripts/build-agents-api-release.sh +build-core-release: + ./scripts/build-core-release.sh -check-agents-api: build-agents-api +check-core: build-core # Persistence integration tests include bounded lifecycle waits that together exceed Go's 10m default. - go test ./services/agents-api/... ./packages/agents-client/... -count=1 -timeout=20m - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s services/agents-api/tests -p 'official_diagnostics_test.py' - PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/e2b/managed_init_test.py + go test ./services/core/... ./packages/agents-client/... -count=1 -timeout=20m + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s services/core/tests -p 'official_diagnostics_test.py' + PYTHONDONTWRITEBYTECODE=1 python3 services/core/deploy/e2b/managed_init_test.py -docker-build-agents-api: - ./scripts/build-agents-api-image.sh +docker-build-core: + ./scripts/build-core-image.sh -check-agents-api-container: docker-build-agents-api - OAC_DEV_CORE_IMAGE="$${OAC_DEV_CORE_IMAGE:-oac-core:dev}" OAC_TEST_SERVER_BIN="$(CURDIR)/services/agents-api/tests/container_server.py" $${OAC_TEST_OFFICIAL_SDK_PYTHON:-python3} services/agents-api/tests/official_client.py +check-core-container: docker-build-core + OAC_DEV_CORE_IMAGE="$${OAC_DEV_CORE_IMAGE:-oac-core:dev}" OAC_TEST_SERVER_BIN="$(CURDIR)/services/core/tests/container_server.py" $${OAC_TEST_OFFICIAL_SDK_PYTHON:-python3} services/core/tests/official_client.py node-deps: pnpm install --frozen-lockfile check-claude-sdk: node-deps - pnpm --filter @parsar/claude-sdk-adapter test + pnpm --filter @oac/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime check-web: node-deps pnpm typecheck pnpm test:core-doctor pnpm test:web - pnpm --filter @agents-core-web/web build + pnpm --filter @oac/web build pnpm test:web:acceptance build-claude-sdk-runtime: @@ -129,13 +129,13 @@ build-microsandbox-provider: @set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/microsandbox-provider"; \ [[ "$$output" == /* ]] || { echo 'Provider output directory must be absolute' >&2; exit 1; }; \ mkdir -p "$$output"; \ - cd services/agents-api/tools/microsandbox-provider; \ + cd services/core/tools/microsandbox-provider; \ GOWORK=off CGO_ENABLED=1 go build -mod=readonly -trimpath -o "$$output/oac-microsandbox-provider" . check-microsandbox-provider: - go test -mod=readonly ./services/agents-api/internal/sandbox/microsandbox/... -count=1 + go test -mod=readonly ./services/core/internal/sandbox/microsandbox/... -count=1 @if [[ "$$(go env GOOS)" == linux ]]; then \ - cd services/agents-api/tools/microsandbox-provider && GOWORK=off CGO_ENABLED=1 go test -mod=readonly ./... -count=1; \ + cd services/core/tools/microsandbox-provider && GOWORK=off CGO_ENABLED=1 go test -mod=readonly ./... -count=1; \ else \ printf 'Skipping the Linux-only microsandbox SDK helper tests; the full Linux gate is required before release.\n'; \ fi @@ -143,7 +143,7 @@ check-microsandbox-provider: .PHONY: check-distribution build-core-distribution check-distribution: node --test scripts/build-native-catalog.test.mjs - go test ./services/core-console -count=1 + go test ./services/web -count=1 PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/install -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/publish-core-release.test.py @@ -151,8 +151,8 @@ check-distribution: PYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/qualification-control.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check - bash -n deploy/install/install.sh deploy/install-release.sh scripts/build-core-console.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh - ./scripts/build-core-console.sh + bash -n deploy/install/install.sh deploy/install-release.sh scripts/build-web.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh + ./scripts/build-web.sh build-core-distribution: ./scripts/build-core-distribution.sh @@ -163,14 +163,14 @@ build-e2b-provider: # The pinned SDK environment is also tested when building the shipped helper. check-e2b-provider: - PYTHONDONTWRITEBYTECODE=1 $${OAC_TEST_E2B_SDK_PYTHON:-python3} -m unittest discover -s services/agents-api/deploy/e2b -p '*_test.py' - PYTHONDONTWRITEBYTECODE=1 $${OAC_TEST_E2B_SDK_PYTHON:-python3} -m unittest discover -s services/agents-api/tools/e2b-provider -p '*_test.py' + PYTHONDONTWRITEBYTECODE=1 $${OAC_TEST_E2B_SDK_PYTHON:-python3} -m unittest discover -s services/core/deploy/e2b -p '*_test.py' + PYTHONDONTWRITEBYTECODE=1 $${OAC_TEST_E2B_SDK_PYTHON:-python3} -m unittest discover -s services/core/tools/e2b-provider -p '*_test.py' .PHONY: check-docs check-docs: node-deps pnpm check:docs -# These packages are also exercised by check-agents-api in the full gate. +# These packages are also exercised by check-core in the full gate. .PHONY: check-sandbox-provider-contract check-sandbox-provider-contract: - go test ./services/agents-api/internal/sandbox/... -count=1 + go test ./services/core/internal/sandbox/... -count=1 diff --git a/README.md b/README.md index 87460c0bd..34b5248cb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ OpenAgentCore runs AI agents on your own infrastructure behind the OpenAI Agents On a Linux amd64 host with Docker and Python 3.9+: ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` Then: diff --git a/README.zh-CN.md b/README.zh-CN.md index 57da12978..2f4008513 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,7 +34,7 @@ OpenAgentCore 在你自己的基础设施上运行 AI Agent,对外提供 OpenA 在已准备 Docker 和 Python 3.9+ 的 Linux amd64 主机上: ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` 然后: diff --git a/apps/parsar-daemon/.gitignore b/apps/daemon/.gitignore similarity index 100% rename from apps/parsar-daemon/.gitignore rename to apps/daemon/.gitignore diff --git a/apps/daemon/cmd/oac-daemon/main.go b/apps/daemon/cmd/oac-daemon/main.go new file mode 100644 index 000000000..368943517 --- /dev/null +++ b/apps/daemon/cmd/oac-daemon/main.go @@ -0,0 +1,19 @@ +// Command oac-daemon is the reverse-WebSocket worker that pairs a user +// machine with a OpenAgentCore server and exposes a local agent CLI +// subprocess as a connector_type=agent_daemon target. See +// apps/daemon/README.md for the subcommand spec. +package main + +import ( + "fmt" + "os" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli" +) + +func main() { + if err := cli.Execute(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "oac-daemon: %v\n", err) + os.Exit(1) + } +} diff --git a/apps/parsar-daemon/internal/agent/binpath/binpath.go b/apps/daemon/internal/agent/binpath/binpath.go similarity index 100% rename from apps/parsar-daemon/internal/agent/binpath/binpath.go rename to apps/daemon/internal/agent/binpath/binpath.go diff --git a/apps/parsar-daemon/internal/agent/binpath/binpath_test.go b/apps/daemon/internal/agent/binpath/binpath_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/binpath/binpath_test.go rename to apps/daemon/internal/agent/binpath/binpath_test.go diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask.go b/apps/daemon/internal/agent/claudecode/ask.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudecode/ask.go rename to apps/daemon/internal/agent/claudecode/ask.go index 29b5fc404..13e38415a 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask.go +++ b/apps/daemon/internal/agent/claudecode/ask.go @@ -6,7 +6,7 @@ import ( "strings" "sync" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // askUserQuestionToolName matches Claude Code's built-in tool name. diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go b/apps/daemon/internal/agent/claudecode/ask_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudecode/ask_test.go rename to apps/daemon/internal/agent/claudecode/ask_test.go index 6dc999113..fa4fc8b3e 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go +++ b/apps/daemon/internal/agent/claudecode/ask_test.go @@ -8,8 +8,8 @@ import ( "sync/atomic" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // askCounterMinter emits ask_001, ask_002, ... for deterministic diff --git a/apps/daemon/internal/agent/claudecode/export_test.go b/apps/daemon/internal/agent/claudecode/export_test.go new file mode 100644 index 000000000..7422b5cc5 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/export_test.go @@ -0,0 +1,90 @@ +package claudecode + +// This file uses _test.go so it only compiles into the test binary, +// but lives in the production package — re-exports internal symbols +// for the external claudecode_test package without polluting the +// public surface. + +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + +func NewPendingTableForTest() *PendingTable { return (*PendingTable)(newPendingTable()) } + +// PendingTable is the test-visible alias for pendingTable. +type PendingTable pendingTable + +type PendingEntry = pendingEntry + +func (p *PendingTable) Record(permID, ccRequestID string, input map[string]any) { + (*pendingTable)(p).Record(permID, ccRequestID, input) +} +func (p *PendingTable) Resolve(permID string) (PendingEntry, bool) { + return (*pendingTable)(p).Resolve(permID) +} +func (p *PendingTable) LookupByCC(ccReq string) (string, bool) { + return (*pendingTable)(p).LookupByCC(ccReq) +} +func (p *PendingTable) Delete(permID string) { (*pendingTable)(p).Delete(permID) } +func (p *PendingTable) Len() int { return (*pendingTable)(p).Len() } + +// PendingAskTable is the test-visible alias for pendingAskTable. +type PendingAskTable pendingAskTable + +type PendingAskEntry = pendingAskEntry + +func NewPendingAskTableForTest() *PendingAskTable { + return (*PendingAskTable)(newPendingAskTable()) +} + +func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { + (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) +} +func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { + entry, ok, _ := (*pendingAskTable)(p).Take(askID, proto.PromptForUserChoiceDecisionPayload{Cancelled: true}) + return entry, ok +} +func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { + p.mu.Lock() + defer p.mu.Unlock() + entry, ok := p.byAskID[askID] + return entry, ok +} +func (p *PendingAskTable) Len() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.byAskID) +} + +// NewTranslatorForTest constructs a translator with a deterministic +// perm-id minter. askPending and askMint default to nil — covers the +// legacy permission-only callers; pass via NewTranslatorWithAskForTest +// when exercising the AskUserQuestion interception path. +func NewTranslatorForTest(runID string, pending *PendingTable, mint func() string) *Translator { + t := newTranslator(runID, (*pendingTable)(pending), nil, permIDMinter(mint), nil) + return (*Translator)(t) +} + +// NewTranslatorWithAskForTest is the ask-aware variant. +func NewTranslatorWithAskForTest(runID string, pending *PendingTable, askPending *PendingAskTable, mint func() string, askMint func() string) *Translator { + t := newTranslator(runID, (*pendingTable)(pending), (*pendingAskTable)(askPending), permIDMinter(mint), askIDMinter(askMint)) + return (*Translator)(t) +} + +type Translator translator + +type Translation = translation + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +// Re-export proto types for the external test package. +type ( + Envelope = proto.Envelope +) + +// BuildAskUserControlResponseForTest exposes the control_request-path +// writeback builder so ask_test.go can pin the control_response shape +// claude's stdin expects under --permission-prompt-tool stdio. +func BuildAskUserControlResponseForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { + return buildAskUserControlResponse(entry, decision) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go b/apps/daemon/internal/agent/claudecode/install_concurrency_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/install_concurrency_test.go rename to apps/daemon/internal/agent/claudecode/install_concurrency_test.go diff --git a/apps/daemon/internal/agent/claudecode/message_input.go b/apps/daemon/internal/agent/claudecode/message_input.go new file mode 100644 index 000000000..ce8a62cc0 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/message_input.go @@ -0,0 +1,41 @@ +package claudecode + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "strings" +) + +func buildOrderedUserMessages(input proto.MessageInput) ([]byte, error) { + if err := input.Validate(); err != nil { + return nil, err + } + var output bytes.Buffer + encoder := json.NewEncoder(&output) + encoder.SetEscapeHTML(false) + for _, message := range input { + var blocks []userContentBlock + for _, part := range message.Content { + if part.Type == "input_text" { + blocks = append(blocks, userContentBlock{Type: "text", Text: *part.Text}) + continue + } + header, data, ok := strings.Cut(*part.ImageURL, ",") + if !ok || (header != "data:image/png;base64" && header != "data:image/jpeg;base64") { + return nil, fmt.Errorf("claudecode: unsupported image reference") + } + mime := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") + blocks = append(blocks, userContentBlock{Type: "image", Source: &userContentSource{Type: "base64", MediaType: mime, Data: data}}) + } + var content any = blocks + if len(blocks) == 1 && blocks[0].Type == "text" { + content = blocks[0].Text + } + if err := encoder.Encode(userMessage{Type: "user", Message: userMessageContent{Role: "user", Content: content}}); err != nil { + return nil, err + } + } + return output.Bytes(), nil +} diff --git a/apps/daemon/internal/agent/claudecode/message_input_test.go b/apps/daemon/internal/agent/claudecode/message_input_test.go new file mode 100644 index 000000000..b3f15e150 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/message_input_test.go @@ -0,0 +1,40 @@ +package claudecode + +import ( + "bytes" + "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "testing" +) + +func TestOrderedMessageInputNativeConversion(t *testing.T) { + image := "data:image/png;base64,aW1hZ2U=" + input := proto.TextInput(" before ") + input[0].Content = append(input[0].Content, proto.InputContent{Type: "input_image", ImageURL: &image}, proto.TextInput(" after ")[0].Content[0]) + input = append(input, proto.TextInput("next")...) + raw, err := buildOrderedUserMessages(input) + if err != nil { + t.Fatal(err) + } + lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("message boundary lost: %s", raw) + } + var first struct { + Message struct{ Content []userContentBlock } + } + if err := json.Unmarshal(lines[0], &first); err != nil { + t.Fatal(err) + } + content := first.Message.Content + if len(content) != 3 || content[0].Text != " before " || content[1].Source == nil || content[1].Source.Data != "aW1hZ2U=" || content[2].Text != " after " { + t.Fatalf("native order changed: %s", raw) + } + image = "https://unsupported.example/image.png" + if _, err := buildOrderedUserMessages(input); err == nil { + t.Fatal("unsupported image reference accepted") + } + if _, err := buildOrderedUserMessages(proto.TextInput("")); err == nil { + t.Fatal("empty message accepted") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/options.go b/apps/daemon/internal/agent/claudecode/options.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/options.go rename to apps/daemon/internal/agent/claudecode/options.go diff --git a/apps/daemon/internal/agent/claudecode/options_test.go b/apps/daemon/internal/agent/claudecode/options_test.go new file mode 100644 index 000000000..f0584eff1 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/options_test.go @@ -0,0 +1,285 @@ +package claudecode_test + +import ( + "encoding/json" + "os" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" +) + +func TestBuildArgsBaseHasStreamFlags(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + wantContains := [][2]string{ + {"--output-format", "stream-json"}, + {"--input-format", "stream-json"}, + {"--permission-prompt-tool", "stdio"}, + } + for _, w := range wantContains { + if !containsPair(res.Args, w[0], w[1]) { + t.Errorf("missing flag pair %s=%s in %v", w[0], w[1], res.Args) + } + } + if !slices.Contains(res.Args, "--verbose") { + t.Errorf("missing --verbose in %v", res.Args) + } + if !slices.Contains(res.Args, "--include-partial-messages") { + t.Errorf("missing --include-partial-messages in %v", res.Args) + } +} + +// IS_SANDBOX=1 must be in every env passthrough. Without it Claude +// Code 2.1.x's root-guard kills the subprocess before the first user +// message. +func TestBuildArgsAlwaysExportsIsSandbox(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !slices.Contains(res.Env, "IS_SANDBOX=1") { + t.Errorf("IS_SANDBOX=1 missing from env, got %v", res.Env) + } +} + +// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta fields +// (e.g. context_management.clear_thinking_*) from /v1/messages bodies; +// internal Anthropic-compatible gateways reject unknown fields with 400. +func TestBuildArgsAlwaysDisablesExperimentalBetas(t *testing.T) { + res, err := claudecode.BuildArgs(nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !slices.Contains(res.Env, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1") { + t.Errorf("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 missing from env, got %v", res.Env) + } +} + +func TestBuildArgsHonoursPrimaryFlags(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "model": "sonnet", + "mode": "acceptEdits", + "allowed_tools": []any{"Bash", "Read", "Write"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--model", "sonnet") { + t.Errorf("--model sonnet not in %v", res.Args) + } + if !containsPair(res.Args, "--permission-mode", "acceptEdits") { + t.Errorf("--permission-mode acceptEdits not in %v", res.Args) + } + if !containsPair(res.Args, "--allowedTools", "Bash,Read,Write") { + t.Errorf("--allowedTools join not in %v", res.Args) + } +} + +func TestBuildArgsResumeUsesExplicitSessionID(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "resume_session_id": "from-map", + }, "from-arg") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--resume", "from-arg") { + t.Errorf("explicit resume id not preferred, args=%v", res.Args) + } + if slices.Contains(res.Args, "from-map") { + t.Errorf("map key leaked into args=%v", res.Args) + } +} + +func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{"resume_session_id": "session_xyz"}, "") + defer res.Cleanup() + if slices.Contains(res.Args, "--resume") || slices.Contains(res.Args, "session_xyz") { + t.Errorf("resume_session_id option must be ignored, args=%v", res.Args) + } +} + +func TestBuildArgsAppendSystemPromptAlone(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{"system_prompt": "be terse"}, "") + defer res.Cleanup() + if !containsPair(res.Args, "--append-system-prompt", "be terse") { + t.Errorf("--append-system-prompt missing, args=%v", res.Args) + } +} + +func TestBuildArgsBypassPermissionsAddsAllowDangerouslyFlag(t *testing.T) { + // Sandbox containers run as root; without + // --allow-dangerously-skip-permissions Claude Code refuses to + // bypass permissions for root callers. + res, err := claudecode.BuildArgs(map[string]any{ + "mode": "bypassPermissions", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--permission-mode", "bypassPermissions") { + t.Errorf("--permission-mode bypassPermissions not in %v", res.Args) + } + if !slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { + t.Errorf("--allow-dangerously-skip-permissions missing for bypass mode, args=%v", res.Args) + } +} + +func TestBuildArgsNonBypassModeOmitsAllowDangerouslyFlag(t *testing.T) { + for _, mode := range []string{"acceptEdits", "default", "plan"} { + res, err := claudecode.BuildArgs(map[string]any{"mode": mode}, "") + if err != nil { + t.Fatalf("BuildArgs(mode=%s): %v", mode, err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { + t.Errorf("mode=%s should not enable allow-dangerously flag, args=%v", mode, res.Args) + } + } +} + +func TestBuildArgsOverrideSystemPromptStripAppend(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are pirate", + }, "") + defer res.Cleanup() + if slices.Contains(res.Args, "--append-system-prompt") { + t.Errorf("override should have stripped append, args=%v", res.Args) + } + if !containsPair(res.Args, "--system-prompt", "you are pirate") { + t.Errorf("--system-prompt missing, args=%v", res.Args) + } +} + +func TestBuildArgsPluginDirsRepeated(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "plugin_dirs": []any{"/a", "/b", "/c"}, + }, "") + defer res.Cleanup() + got := 0 + for i, a := range res.Args { + if a == "--plugin-dir" { + got++ + if i+1 >= len(res.Args) { + t.Fatalf("trailing --plugin-dir without value: %v", res.Args) + } + } + } + if got != 3 { + t.Errorf("expected 3 --plugin-dir flags, got %d in %v", got, res.Args) + } +} + +func TestBuildArgsMCPServersWritesTempfile(t *testing.T) { + res, err := claudecode.BuildArgs(map[string]any{ + "mcp_servers": map[string]any{ + "github": map[string]any{"command": "/usr/local/bin/mcp-github"}, + }, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + // Find the --mcp-config path + path := "" + for i, a := range res.Args { + if a == "--mcp-config" { + if i+1 < len(res.Args) { + path = res.Args[i+1] + } + } + } + if path == "" { + t.Fatalf("--mcp-config path missing, args=%v", res.Args) + } + if !strings.Contains(path, "oac-daemon-mcp-") { + t.Errorf("mcp tempfile name unexpected: %q", path) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat mcp tempfile: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mcp tempfile perm = %o, want 0600", perm) + } + + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read mcp tempfile: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("mcp tempfile not valid json: %v", err) + } + if _, ok := parsed["mcpServers"]; !ok { + t.Errorf("mcp tempfile missing mcpServers wrapper: %s", body) + } + + // Cleanup should remove the file. + res.Cleanup() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("mcp tempfile still exists after Cleanup: stat err=%v", err) + } +} + +func TestBuildArgsEnvPassthroughIsSorted(t *testing.T) { + res, _ := claudecode.BuildArgs(map[string]any{ + "env": map[string]any{ + "ZED": "1", + "ANTHROPIC_KEY": "secret", + "OTHER_FLAG": "x", + }, + }, "") + defer res.Cleanup() + want := []string{"ANTHROPIC_KEY=secret", "OTHER_FLAG=x", "ZED=1"} + // res.Env always starts with the four baseline values; pop them off + // before checking the sort order of the user-supplied tail. + tail := res.Env[4:] + if !slices.Equal(tail, want) { + t.Errorf("env tail = %v, want sorted %v", tail, want) + } +} + +func TestBuildArgsRejectsWrongShapes(t *testing.T) { + cases := []struct { + name string + opts map[string]any + }{ + {"model not string", map[string]any{"model": 7}}, + {"mode not string", map[string]any{"mode": true}}, + {"allowed_tools not array", map[string]any{"allowed_tools": "Bash"}}, + {"plugin_dirs element not string", map[string]any{"plugin_dirs": []any{"/a", 7}}}, + {"mcp_servers not object", map[string]any{"mcp_servers": "json string"}}, + {"env value not string", map[string]any{"env": map[string]any{"K": 1}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := claudecode.BuildArgs(tc.opts, "") + if err == nil { + t.Fatal("BuildArgs accepted bad shape") + } + }) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} diff --git a/apps/daemon/internal/agent/claudecode/parser.go b/apps/daemon/internal/agent/claudecode/parser.go new file mode 100644 index 000000000..8dac63456 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/parser.go @@ -0,0 +1,437 @@ +package claudecode + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "sync/atomic" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// pendingRecorder is the slice of pendingTable the parser needs. +// Interface form lets parser_test.go substitute a fake. +type pendingRecorder interface { + Record(permID, ccRequestID string, input map[string]any) + LookupByCC(ccRequestID string) (string, bool) +} + +// askRecorder is the slice of pendingAskTable the parser needs. +type askRecorder interface { + RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) +} + +// permIDMinter generates the daemon-side permission id (perm_<8hex>). +// Replaceable in tests so envelope IDs are deterministic. +type permIDMinter func() string + +// askIDMinter generates the daemon-side ask id (ask_<8hex>). +// Replaceable in tests so envelope IDs are deterministic. +type askIDMinter func() string + +func defaultPermIDMinter() string { + var b [4]byte + // crypto/rand.Read failure would silently collide perm ids across + // pending requests, causing an approve-on-wrong-tool bug — panic + // loud so operators see it. + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("claudecode: rand.Read for perm id failed: %v", err)) + } + return "perm_" + hex.EncodeToString(b[:]) +} + +func defaultAskIDMinter() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("claudecode: rand.Read for ask id failed: %v", err)) + } + return "ask_" + hex.EncodeToString(b[:]) +} + +// translator converts one NDJSON line from claude stdout into zero or +// more proto.Envelope frames. One translator lives per session. +type translator struct { + runID string + pending pendingRecorder + askPending askRecorder + seq atomic.Uint64 + mint permIDMinter + askMint askIDMinter + partialBlocks map[int]string +} + +func newTranslator(runID string, pending pendingRecorder, askPending askRecorder, mint permIDMinter, askMint askIDMinter) *translator { + if mint == nil { + mint = defaultPermIDMinter + } + if askMint == nil { + askMint = defaultAskIDMinter + } + return &translator{runID: runID, pending: pending, askPending: askPending, mint: mint, askMint: askMint} +} + +// translation is the per-line parser output. +type translation struct { + Envelopes []proto.Envelope + // Terminal is true when this line was a `result` frame — the + // session should stop reading stdout after consuming it. + Terminal bool + // SessionID is the upstream Claude session id surfaced on a system init. + // line (and again on the result frame). session.go writes this + // into binding metadata for --resume. + SessionID string +} + +// rawEnvelope is the minimal shared head every claude stream-json line +// has. +type rawEnvelope struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` +} + +// Translate parses one NDJSON line. Unknown types return an empty +// translation with no error to stay forward-compatible with new claude +// stream variants. Errors are reserved for malformed JSON on frames we +// claim to understand; the session pump treats them as drop-and-log so +// one bad line doesn't kill an otherwise fine run. +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + + var head rawEnvelope + if err := json.Unmarshal(line, &head); err != nil { + return translation{}, fmt.Errorf("claudecode: parse stream-json head: %w", err) + } + + switch head.Type { + case "system": + return t.translateSystem(line) + case "assistant": + return t.translateAssistant(line) + case "stream_event": + return t.translateStreamEvent(line) + case "user": + return t.translateUser(line) + case "control_request": + return t.translateControlRequest(line) + case "control_cancel_request": + return t.translateControlCancel(line) + case "result": + return t.translateResult(line, head.Subtype) + default: + return translation{}, nil + } +} + +// translateStreamEvent handles the raw Anthropic events emitted by Claude +// Code with --include-partial-messages. Claude still emits a complete +// assistant frame after these events, so translateAssistant suppresses its +// text/thinking copies once a corresponding partial delta has been observed. +func (t *translator) translateStreamEvent(line []byte) (translation, error) { + var msg struct { + Event struct { + Type string `json:"type"` + Index int `json:"index"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } `json:"delta"` + } `json:"event"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse stream_event frame: %w", err) + } + if msg.Event.Type != "content_block_delta" { + return translation{}, nil + } + + switch msg.Event.Delta.Type { + case "text_delta": + if msg.Event.Delta.Text == "" { + return translation{}, nil + } + if t.partialBlocks == nil { + t.partialBlocks = make(map[int]string) + } + t.partialBlocks[msg.Event.Index] = "text" + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: msg.Event.Delta.Text, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + case "thinking_delta": + if msg.Event.Delta.Thinking == "" { + return translation{}, nil + } + if t.partialBlocks == nil { + t.partialBlocks = make(map[int]string) + } + t.partialBlocks[msg.Event.Index] = "thinking" + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: msg.Event.Delta.Thinking, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + default: + return translation{}, nil + } +} + +func (t *translator) translateSystem(line []byte) (translation, error) { + var msg struct { + SessionID string `json:"session_id"` + } + // System lines have variable shape (init / compact / etc); missing + // session_id is a no-op, not an error. + _ = json.Unmarshal(line, &msg) + return translation{SessionID: msg.SessionID}, nil +} + +func (t *translator) translateAssistant(line []byte) (translation, error) { + var msg struct { + Message struct { + Content []json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse assistant frame: %w", err) + } + + var envs []proto.Envelope + for index, raw := range msg.Message.Content { + partialIndex := index + // Claude's per-block assistant frames restart content indexes at zero. + if len(msg.Message.Content) == 1 && len(t.partialBlocks) == 1 { + for streamedIndex := range t.partialBlocks { + partialIndex = streamedIndex + } + } + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + switch head.Type { + case "text": + if t.partialBlocks[partialIndex] == "text" { + continue + } + var item struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &item); err != nil || item.Text == "" { + continue + } + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: item.Text, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + case "thinking": + if t.partialBlocks[partialIndex] == "thinking" { + continue + } + var item struct { + Thinking string `json:"thinking"` + } + if err := json.Unmarshal(raw, &item); err != nil || item.Thinking == "" { + continue + } + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: item.Thinking, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + case "tool_use": + var item struct { + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + // Note: AskUserQuestion intentionally NOT intercepted on this + // path. claude-code under --permission-prompt-tool stdio (our + // fixed mode) emits the SAME AskUserQuestion call twice — once + // here as a tool_use, then a few ms later as a control_request + // "can_use_tool" check. Intercepting both would produce two + // PromptForUserChoice envelopes / two cards. The control_request + // path is the one we control end-to-end (claude waits for a + // matching control_response), so the tool_use copy goes + // through as a regular TypeToolCall — the UI logs the call, + // the user still only sees one card from the control_request + // path. See translateControlRequest below. + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: item.ID, + Name: item.Name, + Stage: "before", + Args: item.Input, + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + } + } + // Partial flags apply only to the complete assistant frame that follows + // those stream_event deltas. Reset them so a later assistant turn that is + // delivered without partial frames is not accidentally suppressed. + clear(t.partialBlocks) + return translation{Envelopes: envs}, nil +} + +func (t *translator) translateUser(line []byte) (translation, error) { + var msg struct { + Message struct { + Content []json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse user frame: %w", err) + } + + var envs []proto.Envelope + for _, raw := range msg.Message.Content { + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &head); err != nil { + continue + } + if head.Type != "tool_result" { + continue + } + var item struct { + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: item.ToolUseID, + Stage: "after", + Result: map[string]any{ + "content": decodeToolResultContent(item.Content), + "is_error": item.IsError, + }, + }) + if err != nil { + return translation{}, err + } + envs = append(envs, env) + } + return translation{Envelopes: envs}, nil +} + +// decodeToolResultContent best-effort decodes claude's tool_result +// content field, declared as `string | ContentBlock[]`. Returned as +// the natural Go shape so downstream consumers don't have to re-parse. +func decodeToolResultContent(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + return v +} + +func (t *translator) translateControlRequest(line []byte) (translation, error) { + var msg struct { + RequestID string `json:"request_id"` + Request struct { + Subtype string `json:"subtype"` + ToolName string `json:"tool_name"` + Input map[string]any `json:"input"` + } `json:"request"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse control_request: %w", err) + } + if msg.RequestID == "" { + return translation{}, fmt.Errorf("claudecode: control_request missing request_id") + } + + // AskUserQuestion comes through here too when claude-code runs with + // --permission-prompt-tool stdio. The SDK wraps it as a "can_use_tool" + // permission check rather than emitting a normal tool_use frame, so + // the tool_use-branch interception in translateAssistant never sees + // it. We re-route it into the ask flow here. The CC request_id is + // stashed under askID inside ccByAsk so SubmitPromptForUserChoice can + // write a matching control_response back. + if msg.Request.ToolName == askUserQuestionToolName { + if askEnv, ok := t.interceptAskUserQuestionFromControlRequest(msg.RequestID, msg.Request.Input); ok { + return translation{Envelopes: []proto.Envelope{askEnv}}, nil + } + // Fall through to the permission path when interception can't + // build a valid payload (e.g. questions array missing). claude + // will at least see SOME response on the control_request channel + // rather than blocking; the user will get a permission card they + // can deny. + } + + permID := t.mint() + if t.pending != nil { + t.pending.Record(permID, msg.RequestID, msg.Request.Input) + } + + title := msg.Request.ToolName + if title == "" { + title = "Permission request" + } + env, err := proto.NewEnvelope(proto.TypePermissionRequest, t.runID, proto.PermissionRequestPayload{ + RequestID: permID, + Tool: msg.Request.ToolName, + Title: title, + Payload: msg.Request.Input, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateControlCancel(line []byte) (translation, error) { + var msg struct { + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("claudecode: parse control_cancel_request: %w", err) + } + if msg.RequestID == "" || t.pending == nil { + return translation{}, nil + } + permID, ok := t.pending.LookupByCC(msg.RequestID) + if !ok { + // Approval already came through and the entry was Delete'd — + // drop silently. + return translation{}, nil + } + env, err := proto.NewEnvelope(proto.TypePermissionCancel, permID, nil) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go b/apps/daemon/internal/agent/claudecode/parser_partial_block_test.go similarity index 94% rename from apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go rename to apps/daemon/internal/agent/claudecode/parser_partial_block_test.go index 1abe1f72e..c3bbc11db 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/parser_partial_block_test.go +++ b/apps/daemon/internal/agent/claudecode/parser_partial_block_test.go @@ -3,8 +3,8 @@ package claudecode_test import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestTranslatePerBlockAssistantPreservesStreamWithoutCopies(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_result.go b/apps/daemon/internal/agent/claudecode/parser_result.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudecode/parser_result.go rename to apps/daemon/internal/agent/claudecode/parser_result.go index e7725db05..db6305727 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/parser_result.go +++ b/apps/daemon/internal/agent/claudecode/parser_result.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // resultUsage is split out so we can decode usage even when the diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go b/apps/daemon/internal/agent/claudecode/parser_result_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go rename to apps/daemon/internal/agent/claudecode/parser_result_test.go index 2c11ed1a5..edf3591a9 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/parser_result_test.go +++ b/apps/daemon/internal/agent/claudecode/parser_result_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestTranslateResultErrorDetails(t *testing.T) { diff --git a/apps/daemon/internal/agent/claudecode/parser_test.go b/apps/daemon/internal/agent/claudecode/parser_test.go new file mode 100644 index 000000000..1832ba994 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/parser_test.go @@ -0,0 +1,399 @@ +package claudecode_test + +import ( + "encoding/json" + "fmt" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// counterMinter emits perm_001, perm_002, ... for deterministic +// envelope IDs in tests. +func counterMinter() func() string { + var ( + mu sync.Mutex + n int + ) + return func() string { + mu.Lock() + defer mu.Unlock() + n++ + return fmt.Sprintf("perm_%03d", n) + } +} + +func mustDecode[T any](t *testing.T, raw []byte) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode %T: %v\nraw=%s", v, err, raw) + } + return v +} + +func TestTranslateSystemInitSurfacesSessionID(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_1", nil, counterMinter()) + line := []byte(`{"type":"system","subtype":"init","session_id":"sess_abc","tools":["Bash"]}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if out.SessionID != "sess_abc" { + t.Errorf("SessionID = %q, want sess_abc", out.SessionID) + } + if len(out.Envelopes) != 0 { + t.Errorf("system init must not emit envelopes, got %d", len(out.Envelopes)) + } + if out.Terminal { + t.Errorf("system init is not terminal") + } +} + +func TestTranslateAssistantTextEmitsDelta(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_42", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"hello "}, + {"type":"text","text":"world"} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 2 { + t.Fatalf("want 2 envelopes, got %d", len(out.Envelopes)) + } + for i, env := range out.Envelopes { + if env.Type != "delta" { + t.Errorf("env[%d].Type = %q, want delta", i, env.Type) + } + if env.ID != "run_42" { + t.Errorf("env[%d].ID = %q, want run_42", i, env.ID) + } + } + d1 := mustDecode[struct { + Delta string `json:"delta"` + Sequence uint64 `json:"sequence"` + }](t, out.Envelopes[0].Payload) + d2 := mustDecode[struct { + Delta string `json:"delta"` + Sequence uint64 `json:"sequence"` + }](t, out.Envelopes[1].Payload) + if d1.Delta != "hello " || d2.Delta != "world" { + t.Errorf("delta texts: %q,%q", d1.Delta, d2.Delta) + } + if d1.Sequence == 0 || d2.Sequence <= d1.Sequence { + t.Errorf("sequence not monotonic: %d,%d", d1.Sequence, d2.Sequence) + } +} + +func TestTranslateAssistantThinkingEmitsThinking(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_x", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"thinking","thinking":"let me think...","signature":"sig"} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "thinking" { + t.Fatalf("want one thinking env, got %#v", out.Envelopes) + } + got := mustDecode[struct { + Text string `json:"text"` + }](t, out.Envelopes[0].Payload) + if got.Text != "let me think..." { + t.Errorf("Text = %q", got.Text) + } +} + +func TestTranslatePartialMessagesStreamIncrementallyWithoutAssistantDuplicates(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) + frames := [][]byte{ + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}}`), + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}`), + []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"checking"}}}`), + } + + var got []proto.Envelope + for _, frame := range frames { + out, err := tr.Translate(frame) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + got = append(got, out.Envelopes...) + } + if len(got) != 3 { + t.Fatalf("want 3 partial envelopes, got %d", len(got)) + } + if got[0].Type != proto.TypeDelta || got[1].Type != proto.TypeDelta || got[2].Type != proto.TypeThinking { + t.Fatalf("partial envelope types = %q, %q, %q", got[0].Type, got[1].Type, got[2].Type) + } + + full, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello world"},{"type":"thinking","thinking":"checking"}]}}`)) + if err != nil { + t.Fatalf("Translate complete assistant frame: %v", err) + } + if len(full.Envelopes) != 0 { + t.Fatalf("complete assistant frame duplicated partial content: %#v", full.Envelopes) + } + + next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn without partial frames"}]}}`)) + if err != nil { + t.Fatalf("Translate next complete assistant frame: %v", err) + } + if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("next assistant frame was suppressed by stale partial state: %#v", next.Envelopes) + } +} + +func TestTranslateStreamEventIgnoresNonTextDeltas(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 0 { + t.Fatalf("input JSON delta should not emit an envelope: %#v", out.Envelopes) + } +} + +func TestTranslatePartialMessagesSuppressOnlyMatchingContentBlock(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial_blocks", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed"}}}`)) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + + out, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed"},{"type":"text","text":"complete-only"}]}}`)) + if err != nil { + t.Fatalf("Translate complete frame: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("complete-only block should be preserved: %#v", out.Envelopes) + } + got := mustDecode[struct { + Delta string `json:"delta"` + }](t, out.Envelopes[0].Payload) + if got.Delta != "complete-only" { + t.Fatalf("delta = %q, want complete-only", got.Delta) + } +} + +func TestTranslateResultClearsPartialBlocksBeforeNextTurn(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_partial_error", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial before failure"}}}`)) + if err != nil { + t.Fatalf("Translate partial frame: %v", err) + } + + _, err = tr.Translate([]byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"provider failed"}`)) + if err != nil { + t.Fatalf("Translate error result: %v", err) + } + + next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn"}]}}`)) + if err != nil { + t.Fatalf("Translate next assistant frame: %v", err) + } + if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("next assistant frame was suppressed by result state: %#v", next.Envelopes) + } +} + +func TestTranslateAssistantToolUseEmitsBeforeStage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_t", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"ls"}} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 { + t.Fatalf("want 1 env, got %d", len(out.Envelopes)) + } + got := mustDecode[struct { + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage"` + Args map[string]any `json:"args"` + }](t, out.Envelopes[0].Payload) + if got.ID != "toolu_99" || got.Name != "Bash" || got.Stage != "before" { + t.Errorf("payload mismatch: %+v", got) + } + if got.Args["command"] != "ls" { + t.Errorf("args missing command: %v", got.Args) + } +} + +func TestTranslateAssistantMixedContentMonotonicSequence(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_seq", nil, counterMinter()) + line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"a"}, + {"type":"thinking","thinking":"b"}, + {"type":"text","text":"c"}, + {"type":"tool_use","id":"t","name":"Read","input":{}} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 4 { + t.Fatalf("want 4 envs, got %d", len(out.Envelopes)) + } + var seqs []uint64 + for _, e := range out.Envelopes { + if e.Type == "delta" || e.Type == "thinking" { + d := mustDecode[struct { + Sequence uint64 `json:"sequence"` + }](t, e.Payload) + seqs = append(seqs, d.Sequence) + } + } + for i := 1; i < len(seqs); i++ { + if seqs[i] <= seqs[i-1] { + t.Errorf("sequence not strictly increasing: %v", seqs) + } + } +} + +func TestTranslateUserToolResultEmitsAfterStage(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) + line := []byte(`{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_99","content":"hello\nworld\n","is_error":false} + ]}}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "tool_call" { + t.Fatalf("want one tool_call after env, got %#v", out.Envelopes) + } + got := mustDecode[struct { + ID string `json:"id"` + Stage string `json:"stage"` + Result map[string]any `json:"result"` + }](t, out.Envelopes[0].Payload) + if got.ID != "toolu_99" || got.Stage != "after" { + t.Errorf("got %+v", got) + } + if got.Result["content"] != "hello\nworld\n" { + t.Errorf("content not preserved, got %v", got.Result["content"]) + } + if got.Result["is_error"] != false { + t.Errorf("is_error not preserved: %v", got.Result["is_error"]) + } +} + +func TestTranslateUserToolResultPreservesStructuredContent(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) + line := []byte(`{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":"x"}],"is_error":true} + ]}}`) + out, _ := tr.Translate(line) + got := mustDecode[struct { + Result map[string]any `json:"result"` + }](t, out.Envelopes[0].Payload) + if _, ok := got.Result["content"].([]any); !ok { + t.Errorf("expected []any content block array, got %T %v", got.Result["content"], got.Result["content"]) + } +} + +func TestTranslateControlRequestMintsPermIDAndRecords(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + line := []byte(`{"type":"control_request","request_id":"req_001","request":{ + "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/a"} + }}`) + out, err := tr.Translate(line) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_request" { + t.Fatalf("want one permission_request env, got %#v", out.Envelopes) + } + env := out.Envelopes[0] + if env.ID != "run_z" { + t.Errorf("env.ID = %q, want run_z", env.ID) + } + pr := mustDecode[struct { + RequestID string `json:"request_id"` + Tool string `json:"tool"` + Title string `json:"title"` + Payload map[string]any `json:"payload"` + }](t, env.Payload) + if pr.RequestID != "perm_001" || pr.Tool != "Bash" || pr.Title != "Bash" { + t.Errorf("permission payload mismatch: %+v", pr) + } + if pr.Payload["command"] != "rm -rf /tmp/a" { + t.Errorf("payload not preserved: %v", pr.Payload) + } + entry, ok := pending.Resolve("perm_001") + if !ok || entry.CCRequestID != "req_001" { + t.Errorf("pending table not recorded: %+v ok=%v", entry, ok) + } +} + +func TestTranslateControlCancelLooksUpPerm(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + // Seed by translating the original control_request. + _, _ = tr.Translate([]byte(`{"type":"control_request","request_id":"req_5","request":{"subtype":"can_use_tool","tool_name":"Write","input":{}}}`)) + out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_5"}`)) + if err != nil { + t.Fatalf("Translate cancel: %v", err) + } + if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_cancel" { + t.Fatalf("want one permission_cancel env, got %#v", out.Envelopes) + } + if out.Envelopes[0].ID != "perm_001" { + t.Errorf("cancel env.ID = %q, want perm_001", out.Envelopes[0].ID) + } +} + +func TestTranslateControlCancelUnknownCCIDDropped(t *testing.T) { + pending := claudecode.NewPendingTableForTest() + tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_nope"}`)) + if err != nil { + t.Fatalf("Translate cancel: %v", err) + } + if len(out.Envelopes) != 0 { + t.Errorf("unknown cancel should be dropped, got %#v", out.Envelopes) + } +} + +func TestTranslateUnknownTypeIsNoOp(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + out, err := tr.Translate([]byte(`{"type":"some_future_thing","foo":1}`)) + if err != nil { + t.Fatalf("unknown type should not error: %v", err) + } + if len(out.Envelopes) != 0 || out.Terminal { + t.Errorf("unknown type emitted envelopes/terminal: %#v term=%v", out.Envelopes, out.Terminal) + } +} + +func TestTranslateBlankLineIsNoOp(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + for _, s := range []string{"", " ", "\n", "\t \n"} { + out, err := tr.Translate([]byte(s)) + if err != nil { + t.Errorf("blank line %q errored: %v", s, err) + } + if len(out.Envelopes) != 0 { + t.Errorf("blank line %q emitted envs", s) + } + } +} + +func TestTranslateMalformedJSONReturnsError(t *testing.T) { + tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) + _, err := tr.Translate([]byte(`{"type":"assistant", broken`)) + if err == nil { + t.Error("expected error on malformed JSON") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/permission.go b/apps/daemon/internal/agent/claudecode/permission.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/permission.go rename to apps/daemon/internal/agent/claudecode/permission.go diff --git a/apps/parsar-daemon/internal/agent/claudecode/permission_test.go b/apps/daemon/internal/agent/claudecode/permission_test.go similarity index 95% rename from apps/parsar-daemon/internal/agent/claudecode/permission_test.go rename to apps/daemon/internal/agent/claudecode/permission_test.go index bb1633858..2f4954424 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/permission_test.go +++ b/apps/daemon/internal/agent/claudecode/permission_test.go @@ -3,7 +3,7 @@ package claudecode_test import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" ) func TestPendingTableRoundTrip(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins.go b/apps/daemon/internal/agent/claudecode/plugins.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/plugins.go rename to apps/daemon/internal/agent/claudecode/plugins.go diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins_install.go b/apps/daemon/internal/agent/claudecode/plugins_install.go similarity index 97% rename from apps/parsar-daemon/internal/agent/claudecode/plugins_install.go rename to apps/daemon/internal/agent/claudecode/plugins_install.go index 4478e4379..5fe3dbb2d 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/plugins_install.go +++ b/apps/daemon/internal/agent/claudecode/plugins_install.go @@ -10,8 +10,8 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/claudecode/plugins_test.go b/apps/daemon/internal/agent/claudecode/plugins_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/plugins_test.go rename to apps/daemon/internal/agent/claudecode/plugins_test.go diff --git a/apps/daemon/internal/agent/claudecode/session.go b/apps/daemon/internal/agent/claudecode/session.go new file mode 100644 index 000000000..145725764 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/session.go @@ -0,0 +1,705 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "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/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +// sessionConfig customises Factory for tests (alternative binary path, +// alternative logger, shorter SIGTERM→SIGKILL escalation). +type sessionConfig struct { + // claudeBinary defaults to binpath.ClaudeCode(): the bare name + // "claude" for a PATH lookup, or the OAC_RUNTIME_CLAUDE_BIN override. + claudeBinary string + + // extraArgs are appended after BuildArgs' output. Tests use this + // for the os/exec helper-process pattern. + extraArgs []string + + // killTimeout is how long Cancel waits for SIGTERM to drain + // before SIGKILL. 3s in production; tests pin it short. + killTimeout time.Duration + + // askTimeout bounds how long the daemon waits for the human to answer a + // permission or prompt_for_user_choice (AskUserQuestion). 10 minutes in + // production; tests pin it short so timeout paths are exercisable. + // Zero disables the timer — leftover scaffolding for very early + // adapter wiring; production always picks defaultAskTimeout. + askTimeout time.Duration + + logger *slog.Logger +} + +const defaultAskTimeout = 10 * time.Minute + +func defaultConfig() sessionConfig { + return sessionConfig{ + claudeBinary: binpath.ClaudeCode(), + killTimeout: 3 * time.Second, + askTimeout: defaultAskTimeout, + logger: obslog.Bg(), + } +} + +// Factory implements agent.Factory for agent_kind="claude_code". +// Register during daemon startup: +// +// Register the factory with an explicit capability descriptor using Registry.RegisterKind. +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `claude` CLI subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + stdin io.WriteCloser + stdinMu sync.Mutex + + pending *pendingTable + askPending *pendingAskTable + translator *translator + + out chan<- proto.Envelope + closeOutOnce sync.Once + outMu sync.RWMutex + outClosed bool + + // cancelCtx is a child of parent ctx so Session.Cancel can signal + // everyone without racing router shutdown. + cancelCtx context.Context + + cancelOnce sync.Once + + // interactionTimersMu guards permission and AskUserQuestion watchdogs. + // Timeout callbacks and human responses race through atomic pending + // tables, so only one response can reach Claude Code. + interactionTimersMu sync.Mutex + interactionTimers map[string]*time.Timer + + // latestSessionID is the most recent upstream session id seen on a + // system-init / result frame. The cancel-path done envelope reads + // it back so the server can RememberSession even when claude was + // killed mid-prompt (without it, the next user message starts a + // brand-new chat with no --resume). + latestSessionIDMu sync.Mutex + latestSessionID string + + buildCleanup func() +} + +var _ agent.Session = (*Session)(nil) + +// newSession is the internal constructor; cfg lets tests inject a fake +// claude binary and a short kill timeout. +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("claudecode: nil out channel") + } + if err := req.Input.Validate(); err != nil { + return nil, err + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.claudeBinary == "" { + cfg.claudeBinary = binpath.ClaudeCode() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + cfg.logger.Info("claudecode: newSession start", + "run_id", req.RunID, "agent_kind", req.AgentKind, + "prompt_len", len(req.Input), "work_dir", req.WorkDir, + "has_agent_options", req.AgentOptions != nil, + "agent_session_id", req.AgentSessionID, + "claude_binary", cfg.claudeBinary) + + // Install plugins BEFORE BuildArgs so the resolved local paths + // can be folded into opts["plugin_dirs"]. installPlugins demotes + // individual plugins to warnings; a hard error (e.g. mkdir fail) + // aborts the session. + // + // sessionWorkDir is reused for cmd.Dir below so plugins land at + // /.claude/plugins/ and the claude subprocess sees + // them at cwd-relative paths. + sessionWorkDir, err := resolveSessionWorkDir(req.WorkDir, req.ConversationID) + if err != nil { + cfg.logger.Error("claudecode: resolveSessionWorkDir failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: resolve session workDir: %w", err) + } + if sessionWorkDir != req.WorkDir { + cfg.logger.Info("claudecode: req.WorkDir empty, using resolved session dir", + "run_id", req.RunID, "session_dir", sessionWorkDir) + } + + pluginOpts := req.AgentOptions + if rawPlugins, ok := pluginOpts["plugins"]; ok { + descriptors, decodeWarns := decodePluginDescriptors(rawPlugins) + for _, w := range decodeWarns { + cfg.logger.Warn("claudecode: plugin descriptor decode warning", + "run_id", req.RunID, "msg", w) + } + installRes, err := installPlugins(parent, cfg.logger, sessionWorkDir, descriptors) + if err != nil { + cfg.logger.Error("claudecode: installPlugins failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: install plugins: %w", err) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("claudecode: plugin install warning", + "run_id", req.RunID, "msg", w) + } + if len(installRes.PluginDirs) > 0 { + // Defensive copy so we never mutate the caller's map. + // Existing plugin_dirs (hand-configured override) wins; + // capability-resolved dirs append. + pluginOpts = cloneAgentOptions(req.AgentOptions) + pluginOpts["plugin_dirs"] = mergePluginDirs(pluginOpts["plugin_dirs"], installRes.PluginDirs) + } + cfg.logger.Info("claudecode: plugins installed", + "run_id", req.RunID, + "plugin_count", len(descriptors), + "dir_count", len(installRes.PluginDirs)) + } + + // Skills install to /.claude/skills//, which + // Claude Code auto-scans at startup. No CLI flag, no opts mutation. + if rawSkills, ok := pluginOpts["skills"]; ok { + descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) + for _, w := range decodeWarns { + cfg.logger.Warn("claudecode: skill descriptor decode warning", + "run_id", req.RunID, "msg", w) + } + installRes, err := installSkills(parent, cfg.logger, sessionWorkDir, descriptors) + if err != nil { + cfg.logger.Error("claudecode: installSkills failed", + "run_id", req.RunID, "err", err.Error()) + return nil, fmt.Errorf("claudecode: install skills: %w", err) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("claudecode: skill install warning", + "run_id", req.RunID, "msg", w) + } + cfg.logger.Info("claudecode: skills installed", + "run_id", req.RunID, + "skill_count", len(descriptors), + "warn_count", len(installRes.Warnings)) + } + + buildRes, err := BuildArgs(pluginOpts, req.AgentSessionID) + if err != nil { + cfg.logger.Error("claudecode: BuildArgs failed", "run_id", req.RunID, "err", err) + return nil, fmt.Errorf("claudecode: build args: %w", err) + } + cfg.logger.Info("claudecode: BuildArgs ok", + "run_id", req.RunID, "arg_count", len(buildRes.Args), "env_count", len(buildRes.Env)) + + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + + cfg.logger.Info("claudecode: starting subprocess", + "run_id", req.RunID, "binary", cfg.claudeBinary, + "arg_count", len(args), "dir", sessionWorkDir) + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.claudeBinary, + Args: args, + Dir: sessionWorkDir, + Env: append(os.Environ(), buildRes.Env...), + NeedStdin: true, + KillTimeout: cfg.killTimeout, + }) + if err != nil { + cfg.logger.Error("claudecode: cmd.Start failed", + "run_id", req.RunID, "binary", cfg.claudeBinary, "err", err) + buildRes.Cleanup() + return nil, fmt.Errorf("claudecode: start %q: %w", cfg.claudeBinary, err) + } + cfg.logger.Info("claudecode: subprocess started", + "run_id", req.RunID, "pid", proc.Cmd.Process.Pid) + + pending := newPendingTable() + askPending := newPendingAskTable() + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + stdin: proc.Stdin, + pending: pending, + askPending: askPending, + translator: newTranslator(req.RunID, pending, askPending, defaultPermIDMinter, defaultAskIDMinter), + out: out, + cancelCtx: proc.Context(), + interactionTimers: make(map[string]*time.Timer), + buildCleanup: buildRes.Cleanup, + } + + // Write the initial user message before launching pumps so the + // first stdout line corresponds to the prompt we just sent. Best + // effort: write failure → pump sees EOF and synthesises + // error+done. + if msg, err := buildOrderedUserMessages(req.Input); err == nil { + cfg.logger.Info("claudecode: writing initial user message to stdin", + "run_id", req.RunID, "msg_bytes", len(msg)) + if _, werr := s.writeStdin(msg); werr != nil { + cfg.logger.Warn("claudecode: write initial user message", + "run_id", req.RunID, "err", werr) + } else { + cfg.logger.Info("claudecode: initial user message written ok", "run_id", req.RunID) + } + } else { + cfg.logger.Warn("claudecode: build user message", + "run_id", req.RunID, "err", err) + } + + cfg.logger.Info("claudecode: launching stdout/stderr pumps", "run_id", req.RunID) + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + + return s, nil +} + +// writeStdin appends to claude's stdin under a mutex (claude only +// promises NDJSON framing; concurrent writes from SubmitPermission and +// the initial user-message write must not tear). +func (s *Session) writeStdin(b []byte) (int, error) { + s.stdinMu.Lock() + defer s.stdinMu.Unlock() + return s.stdin.Write(b) +} + +// Cancel asks the subprocess to stop. SIGTERM, escalating to SIGKILL +// after cfg.killTimeout. Idempotent; the actual teardown (out chan +// close) happens asynchronously via the pump. +func (s *Session) Cancel(_ context.Context) error { + s.cancelOnce.Do(func() { + s.stopAllInteractionTimers() + s.proc.Cancel() + }) + return nil +} + +// stopAllInteractionTimers cancels every outstanding human-response +// watchdog. Called on session Cancel/terminal so timers cannot fire into a +// closed stdin. +func (s *Session) stopAllInteractionTimers() { + s.interactionTimersMu.Lock() + timers := s.interactionTimers + s.interactionTimers = make(map[string]*time.Timer) + s.interactionTimersMu.Unlock() + for _, t := range timers { + t.Stop() + } +} + +// SubmitPermission writes a control_response back to claude for the +// given perm_id. Returns agent.ErrUnknownPermission when permID isn't +// in the pending table. +func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { + entry, ok := s.pending.Take(permID) + if !ok { + return agent.ErrUnknownPermission + } + s.stopInteractionTimer(permID) + + var inner map[string]any + if decision.Approved { + updatedInput := decision.UpdatedInput + if updatedInput == nil { + updatedInput = entry.Input + } + inner = map[string]any{ + "behavior": "allow", + "updatedInput": updatedInput, + } + } else { + msg := decision.Message + if msg == "" { + msg = "denied by operator" + } + inner = map[string]any{ + "behavior": "deny", + "message": msg, + } + } + + body, err := json.Marshal(map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": entry.CCRequestID, + "response": inner, + }, + }) + if err != nil { + return fmt.Errorf("claudecode: marshal control_response: %w", err) + } + body = append(body, '\n') + + if _, err := s.writeStdin(body); err != nil { + // Restore the entry so a transient stdin write failure remains + // retryable and still has a bounded lifetime. + s.pending.Record(permID, entry.CCRequestID, entry.Input) + s.startPermissionTimer(permID) + return fmt.Errorf("claudecode: write control_response: %w", err) + } + return nil +} + +// SubmitPromptForUserChoice writes a tool_result back into claude's +// stdin for the AskUserQuestion call the daemon intercepted. Returns +// agent.ErrUnknownAsk when askID isn't in the pending ask table — +// usually a race with Cancel or a duplicate decision from the server. +// +// The reply is shaped as a normal Claude Code tool_result message; the +// model resumes its turn as if the local SDK had executed the tool +// and supplied the human's answer. +// +// Cancelled answers (timeout, operator /cancel) still write back +// is_error=false text — see plan: returning is_error=true would push +// the agent into a "retry the same tool" loop, which is worse UX than +// telling it "the user stopped, fold and report". +func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + // Take is atomic read+delete; the timer-fired cancel and a server- + // delivered answer can both reach here, but only one wins. The + // loser sees ok=false and returns ErrUnknownAsk — the router logs + // and moves on. + entry, ok, err := s.askPending.Take(askID, decision) + if err != nil { + return err + } + if !ok { + return agent.ErrUnknownAsk + } + + // Drop the timer so its callback (if pending) finds the entry gone + // and returns silently. Already-fired callbacks lost the Take race + // above; stop is best-effort either way. + s.stopAskTimer(askID) + + body, buildEr := buildAskUserControlResponse(entry, decision) + if buildEr != nil { + return fmt.Errorf("claudecode: marshal ask reply: %w", buildEr) + } + if _, err := s.writeStdin(body); err != nil { + // Entry is already gone (Take consumed it). A retry will see + // ErrUnknownAsk; the session is going to die anyway when stdin + // errors, so we don't try to restore the entry. + return fmt.Errorf("claudecode: write ask reply: %w", err) + } + return nil +} + +// startAskTimer launches a single-shot watchdog that times the human +// out after cfg.askTimeout. On fire, the watchdog submits a Cancelled +// decision against itself so the agent's tool_result lands the same +// way as if the operator had clicked "stop" — no special "timeout" +// branch in SubmitPromptForUserChoice. +func (s *Session) startAskTimer(askID string) { + if s.cfg.askTimeout <= 0 { + return + } + timer := time.AfterFunc(s.cfg.askTimeout, func() { + _ = s.SubmitPromptForUserChoice(context.Background(), askID, proto.PromptForUserChoiceDecisionPayload{ + Cancelled: true, + Reason: "timeout", + }) + }) + s.interactionTimersMu.Lock() + if prev, ok := s.interactionTimers[askID]; ok { + // Same askID seen twice: cancel the old timer so we don't fire + // two decisions. Shouldn't happen on a clean stream, but two + // stdout-pump dispatches with the same env.ID would otherwise + // race. + prev.Stop() + } + s.interactionTimers[askID] = timer + s.interactionTimersMu.Unlock() +} + +func (s *Session) stopAskTimer(askID string) { + s.stopInteractionTimer(askID) +} + +// startPermissionTimer applies the same bounded human-response window to +// tool approvals. Expiry is an explicit deny, never an implicit allow. +func (s *Session) startPermissionTimer(permID string) { + if s.cfg.askTimeout <= 0 || permID == "" { + return + } + timer := time.AfterFunc(s.cfg.askTimeout, func() { + _ = s.SubmitPermission(context.Background(), permID, proto.PermissionDecisionPayload{ + Approved: false, + Message: "permission request timed out", + }) + }) + s.interactionTimersMu.Lock() + if prev, ok := s.interactionTimers[permID]; ok { + prev.Stop() + } + s.interactionTimers[permID] = timer + s.interactionTimersMu.Unlock() +} + +func (s *Session) stopInteractionTimer(id string) { + s.interactionTimersMu.Lock() + timer, ok := s.interactionTimers[id] + if ok { + delete(s.interactionTimers, id) + } + s.interactionTimersMu.Unlock() + if ok { + timer.Stop() + } +} + +// run is the stdout pump. Owns the out channel close. +func (s *Session) run(stdout io.Reader) { + s.cfg.logger.Info("claudecode: run() stdout pump started", "run_id", s.runID) + defer s.buildCleanup() + defer s.closeOut() + + sc := bufio.NewScanner(stdout) + // Claude can emit very large tool_result lines in one frame; 16MB + // is far above any practical single-tool output. + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + lineCount := 0 + terminal := false + for sc.Scan() { + line := sc.Bytes() + lineCount++ + s.cfg.logger.Info("claudecode: stdout line", + "run_id", s.runID, "line_num", lineCount, "len", len(line), + "head", string(line[:min(len(line), 200)])) + tx, err := s.translator.Translate(line) + if err != nil { + s.cfg.logger.Warn("claudecode: translate line", + "run_id", s.runID, "err", err, "len", len(line)) + continue + } + if id := strings.TrimSpace(tx.SessionID); id != "" { + s.latestSessionIDMu.Lock() + s.latestSessionID = id + s.latestSessionIDMu.Unlock() + } + s.cfg.logger.Info("claudecode: translated", + "run_id", s.runID, "line_num", lineCount, + "envelope_count", len(tx.Envelopes), "terminal", tx.Terminal) + for _, env := range tx.Envelopes { + select { + case s.out <- env: + s.cfg.logger.Info("claudecode: envelope sent to out", + "run_id", s.runID, "type", env.Type, "env_id", env.ID) + if env.Type == proto.TypePromptForUserChoice { + // Start the human-answer watchdog AFTER the envelope + // has been handed off — counting the 10-minute window + // from "router has it" not "we're about to try". A + // slow consumer that blocked us on the send shouldn't + // also burn timeout budget the human never saw. + // + // Late-answer race: if the router somehow delivers a + // decision before we finish startAskTimer, the Take + // inside SubmitPromptForUserChoice still wins + // exclusively; the timer just becomes a no-op when it + // fires. + // + // Ask id lives on the payload now (env.ID is the run + // id so server-side dispatch can fan to the run's + // subscriber); decode just enough to seed the timer. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err == nil && p.AskID != "" { + s.startAskTimer(p.AskID) + } + } else if env.Type == proto.TypePermissionRequest { + var p proto.PermissionRequestPayload + requestID := "" + if err := env.DecodePayload(&p); err == nil { + requestID = strings.TrimSpace(p.RequestID) + } + if requestID == "" { + requestID = strings.TrimSpace(env.ID) + } + if requestID != "" { + s.startPermissionTimer(requestID) + } + } + case <-s.cancelCtx.Done(): + s.cfg.logger.Info("claudecode: cancelled during out send", "run_id", s.runID) + _ = s.proc.Wait() + return + } + } + if tx.Terminal { + terminal = true + s.stopAllInteractionTimers() + s.closeOut() + break + } + } + if err := sc.Err(); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) { + s.cfg.logger.Warn("claudecode: scan stdout", + "run_id", s.runID, "err", err) + } + s.cfg.logger.Info("claudecode: stdout pump exiting", + "run_id", s.runID, "lines_read", lineCount, "terminal", terminal) + + waitErr := s.proc.Wait() + s.cfg.logger.Info("claudecode: subprocess exited", + "run_id", s.runID, "wait_err", waitErr, + "exit_code", s.proc.Cmd.ProcessState.ExitCode()) + + if !terminal { + s.synthesizeTerminal(waitErr) + } +} + +// pumpStderr drains and logs stderr so the subprocess doesn't block +// on a full pipe. +func (s *Session) pumpStderr(stderr io.Reader) { + s.cfg.logger.Info("claudecode: pumpStderr started", "run_id", s.runID) + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + lineCount := 0 + for sc.Scan() { + lineCount++ + s.cfg.logger.Warn("claude stderr", + "run_id", s.runID, "line", sc.Text()) + } + s.cfg.logger.Info("claudecode: pumpStderr done", "run_id", s.runID, "lines", lineCount) +} + +// synthesizeTerminal emits error+done when the subprocess exited +// without a result frame. +func (s *Session) synthesizeTerminal(waitErr error) { + msg := "claude_code: subprocess exited without result" + if waitErr != nil { + msg = fmt.Sprintf("claude_code: subprocess exited: %v", waitErr) + } + if s.cancelCtx.Err() != nil { + msg = "claude_code: cancelled" + } + if errEnv, err := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: msg}); err == nil { + s.trySend(errEnv) + } + if doneEnv, err := proto.NewEnvelope(proto.TypeDone, s.runID, proto.DonePayload{Metadata: s.doneMetaForCancel()}); err == nil { + s.trySend(doneEnv) + } +} + +// doneMetaForCancel returns the metadata map attached to the cancel-path Done envelope. +func (s *Session) doneMetaForCancel() map[string]any { + s.latestSessionIDMu.Lock() + id := s.latestSessionID + s.latestSessionIDMu.Unlock() + if id == "" { + return nil + } + return map[string]any{ + proto.DoneMetaAgentSessionID: id, + proto.DoneMetaAgentSessionType: "claude_session", + } +} + +func (s *Session) trySend(env proto.Envelope) { + s.outMu.RLock() + defer s.outMu.RUnlock() + if s.outClosed { + return + } + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("claudecode: terminal send timed out", + "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { + s.closeOutOnce.Do(func() { + s.outMu.Lock() + s.outClosed = true + close(s.out) + s.outMu.Unlock() + }) +} + +// resolveSessionWorkDir returns the directory that BOTH plugin installs +// AND the claude_code subprocess cwd share for this run. Keeping them +// on the same tree prevents the bug where the subprocess ran in one +// place (sandbox image WORKDIR) while plugins sat under ~/.oac/ +// — `--plugin-dir` still worked but the agent's own `ls .claude/ +// plugins/` self-check answered "no plugins here". +// +// Resolution order: +// +// 1. req.WorkDir wins. Local mode where the operator pinned a project +// root. Must be absolute or start with ~/ (we reject relative paths +// instead of resolving them against daemon cwd, since the daemon's cwd is +// not a meaningful anchor for user-facing config) and we mkdir -p +// so the user can name a path that doesn't exist yet. +// 2. conversationID present → per-conversation scratch dir under +// daemon HOME (~/.oac/runtime/claudecode/conv-). +// Consecutive turns reuse the same .cache-key files. Sandbox-mode +// default, also the local fallback when work_dir is unbound. +// 3. Both empty → daemon's own cwd (os.Getwd). Backstop matching +// pre-plugin behavior. +// +// Errors propagate so the eventual "could not extract zip" gets a +// clearer message. +func resolveSessionWorkDir(workDir, conversationID string) (string, error) { + if trimmed := strings.TrimSpace(workDir); trimmed != "" { + if strings.HasPrefix(trimmed, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + trimmed = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + } else if !filepath.IsAbs(trimmed) { + return "", fmt.Errorf("work_dir must be an absolute path, got %q", trimmed) + } + if err := os.MkdirAll(trimmed, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", trimmed, err) + } + return trimmed, nil + } + if convID := strings.TrimSpace(conversationID); convID != "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("os.UserHomeDir: %w", err) + } + dir := filepath.Join(home, ".oac", "runtime", "claudecode", "conv-"+convID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", dir, err) + } + return dir, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("os.Getwd: %w", err) + } + return cwd, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_export_test.go b/apps/daemon/internal/agent/claudecode/session_export_test.go similarity index 94% rename from apps/parsar-daemon/internal/agent/claudecode/session_export_test.go rename to apps/daemon/internal/agent/claudecode/session_export_test.go index 0021b8845..6876188f0 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/session_export_test.go +++ b/apps/daemon/internal/agent/claudecode/session_export_test.go @@ -7,7 +7,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type SessionConfigForTest struct { diff --git a/apps/daemon/internal/agent/claudecode/session_knowledge_test.go b/apps/daemon/internal/agent/claudecode/session_knowledge_test.go new file mode 100644 index 000000000..3ca8200d4 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/session_knowledge_test.go @@ -0,0 +1,30 @@ +package claudecode + +import ( + "bytes" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestStartupLogsDoNotContainReferenceDocuments(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var output bytes.Buffer + const privateDocument = "PRIVATE-REFERENCE-9481" + _, err := newSession(t.Context(), proto.PromptRequestPayload{ + RunID: "knowledge-log-check", WorkDir: t.TempDir(), Input: proto.TextInput("Answer from the reference."), + AgentOptions: map[string]any{"system_prompt": privateDocument}, + }, make(chan proto.Envelope, 8), sessionConfig{ + claudeBinary: filepath.Join(t.TempDir(), "missing-claude"), + logger: slog.New(slog.NewTextHandler(&output, nil)), + }) + if err == nil || !strings.Contains(output.String(), "starting subprocess") { + t.Fatalf("did not exercise subprocess startup: %v", err) + } + if strings.Contains(output.String(), privateDocument) { + t.Fatal("reference documents leaked into startup logs") + } +} diff --git a/apps/daemon/internal/agent/claudecode/session_test.go b/apps/daemon/internal/agent/claudecode/session_test.go new file mode 100644 index 000000000..266bcfc5d --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/session_test.go @@ -0,0 +1,622 @@ +package claudecode_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `claude` when +// CLAUDECODE_TESTHELPER_ROLE is set, bypassing m.Run so the test +// framework's PASS line never pollutes the fake stdout. +const helperEnvKey = "CLAUDECODE_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(helperEnvKey); role != "" { + runFakeClaude(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +// runFakeClaude pretends to be the `claude` CLI in stream-json mode. +func runFakeClaude(role string) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + stdin := bufio.NewScanner(os.Stdin) + stdin.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + // Wait for the daemon's initial user message so stream-json frame + // ordering is deterministic. + _ = stdin.Scan() + + switch role { + case "echo-success": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_echo", + }) + _ = enc.Encode(map[string]any{ + "type": "assistant", + "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{ + {"type": "text", "text": "hi there"}, + }, + }, + }) + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "hi there", + "session_id": "sess_echo", + "usage": map[string]int{"input_tokens": 5, "output_tokens": 2}, + }) + + case "terminal-wait": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_terminal_wait", + }) + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "background work started", + "session_id": "sess_terminal_wait", + }) + for stdin.Scan() { + } + + case "echo-error": + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "error_during_execution", + "is_error": true, "error": "boom from fake", + }) + + case "permission": + _ = enc.Encode(map[string]any{ + "type": "control_request", "request_id": "req_cc_42", + "request": map[string]any{ + "subtype": "can_use_tool", "tool_name": "Bash", + "input": map[string]any{"command": "ls"}, + }, + }) + // Wait for the daemon's control_response. + approved := false + ccID := "" + if stdin.Scan() { + var decision struct { + Type string `json:"type"` + Response struct { + RequestID string `json:"request_id"` + Response struct { + Behavior string `json:"behavior"` + } `json:"response"` + } `json:"response"` + } + if err := json.Unmarshal(stdin.Bytes(), &decision); err == nil { + approved = decision.Response.Response.Behavior == "allow" + ccID = decision.Response.RequestID + } + } + text := "denied for " + ccID + if approved { + text = "allowed for " + ccID + } + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": text, + }) + + case "hang": + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_hang", + }) + // Long sleep keeps a runtime timer alive so Go's deadlock + // detector doesn't panic to stderr. SIGTERM still kills it. + time.Sleep(10 * time.Minute) + + case "ask-question": + // Stream an AskUserQuestion as a control_request (the path + // claude-code takes under --permission-prompt-tool stdio). + // Block on stdin waiting for the daemon's control_response + // carrying the human's answer; echo it back via the final + // result frame so the test can assert the round-trip text. + _ = enc.Encode(map[string]any{ + "type": "system", "subtype": "init", + "session_id": "sess_ask", + }) + _ = enc.Encode(map[string]any{ + "type": "control_request", + "request_id": "cc_req_ask_1", + "request": map[string]any{ + "subtype": "can_use_tool", + "tool_name": "AskUserQuestion", + "input": map[string]any{ + "questions": []map[string]any{{ + "header": "Confirm delete", + "question": "Delete /tmp directory?", + "multiSelect": false, + "options": []map[string]any{ + {"label": "Confirm delete", "description": "Run rm -rf"}, + {"label": "Cancel", "description": "Do not run"}, + }, + }}, + }, + }, + }) + + // Wait for the daemon's control_response. Body shape: + // {type:"control_response", response:{subtype:"success", + // request_id:"...", response:{behavior:"deny", message:"..."}}} + answerText := "" + if stdin.Scan() { + var cr struct { + Type string `json:"type"` + Response struct { + Subtype string `json:"subtype"` + Response struct { + Behavior string `json:"behavior"` + Message string `json:"message"` + } `json:"response"` + } `json:"response"` + } + if err := json.Unmarshal(stdin.Bytes(), &cr); err == nil { + answerText = cr.Response.Response.Message + } + } + _ = enc.Encode(map[string]any{ + "type": "result", "subtype": "success", + "result": "echoed:" + answerText, + "session_id": "sess_ask", + }) + } +} + +func helperConfig() claudecode.SessionConfigForTest { + return claudecode.SessionConfigForTest{ + ClaudeBinary: os.Args[0], + // belt-and-braces: -test.run=^$ stops any tests from running + // if TestMain forgets to short-circuit. + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +// helperReq points the helper at a specific role via env passthrough. +func helperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Input: proto.TextInput(prompt), + AgentOptions: map[string]any{ + "env": map[string]any{ + helperEnvKey: role, + }, + }, + } +} + +// drain reads envelopes until out closes or dl fires. Second return +// is true on a clean close, false on timeout. +func drain(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionEndToEndSuccess(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_s", "hello", "echo-success"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + if len(got) == 0 { + t.Fatal("got no envelopes") + } + if got[len(got)-1].Type != "done" { + t.Errorf("last env type = %q, want done", got[len(got)-1].Type) + } + + types := envTypes(got) + mustContain(t, types, "delta") + mustContain(t, types, "usage") + mustContain(t, types, "done") + for _, e := range got { + if e.ID != "run_s" { + t.Errorf("env type=%s ID=%q, want run_s", e.Type, e.ID) + } + } +} + +func TestTerminalResultKeepsProcessAliveUntilCancel(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_terminal_wait", "start background work", "terminal-wait"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envelopes", len(got)) + } + mustContain(t, envTypes(got), proto.TypeDone) + + select { + case <-sess.ProcessDoneForTest(): + t.Fatal("terminal result killed the CLI process before the idle timeout") + case <-time.After(50 * time.Millisecond): + } + + if err := sess.Cancel(context.Background()); err != nil { + t.Fatalf("Cancel: %v", err) + } + select { + case <-sess.ProcessDoneForTest(): + case <-time.After(2 * time.Second): + t.Fatal("CLI process did not exit after explicit cancel") + } +} + +func TestSessionEndToEndError(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_e", "hello", "echo-error"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + mustContain(t, types, "error") + mustContain(t, types, "done") + if got[len(got)-1].Type != "done" { + t.Errorf("last env not done: %s", got[len(got)-1].Type) + } +} + +func TestSessionCancelClosesOut(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_c", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + // Give the helper a moment to emit the system init line. + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := envTypes(got) + // Synthesised cancel terminal: error + done. + mustContain(t, types, "done") +} + +func TestSessionCancelIsIdempotent(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_c2", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + _ = sess.Cancel(context.Background()) + _ = sess.Cancel(context.Background()) + _ = sess.Cancel(context.Background()) + _, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after redundant Cancels") + } +} + +func TestSessionSubmitPermissionUnknownReturnsErrUnknown(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_p0", "hello", "hang"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + err = sess.SubmitPermission(context.Background(), "perm_neverseen", + proto.PermissionDecisionPayload{Approved: true}) + if !errors.Is(err, agent.ErrUnknownPermission) { + t.Errorf("SubmitPermission for unknown id err = %v, want ErrUnknownPermission", err) + } +} + +func TestSessionPermissionRoundTrip(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_p", "approve me", "permission"), out, helperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + // Drain until we see the permission_request, then approve it. + permID := "" + deadline := time.After(5 * time.Second) + var collected []proto.Envelope + for permID == "" { + select { + case env, ok := <-out: + if !ok { + t.Fatalf("out closed before permission_request; collected %d", len(collected)) + } + collected = append(collected, env) + if env.Type == "permission_request" { + if env.ID != "run_p" { + t.Fatalf("permission env.ID = %q, want run_p", env.ID) + } + var request proto.PermissionRequestPayload + if err := env.DecodePayload(&request); err != nil { + t.Fatalf("decode permission request: %v", err) + } + permID = request.RequestID + } + case <-deadline: + t.Fatalf("timeout waiting for permission_request; collected %d", len(collected)) + } + } + if !strings.HasPrefix(permID, "perm_") { + t.Errorf("perm id wrong shape: %q", permID) + } + + if err := sess.SubmitPermission(context.Background(), permID, + proto.PermissionDecisionPayload{Approved: true}); err != nil { + t.Fatalf("SubmitPermission: %v", err) + } + + // Drain the rest. Expect to land at done with "allowed" content. + rest, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after approval") + } + all := append(collected, rest...) + final := all[len(all)-1] + if final.Type != "done" { + t.Errorf("final env type = %q, want done", final.Type) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.HasPrefix(done.Content, "allowed for ") { + t.Errorf("done.content = %q, want 'allowed for ...'", done.Content) + } + + // After resolution the perm id should no longer be known. + err = sess.SubmitPermission(context.Background(), permID, + proto.PermissionDecisionPayload{Approved: true}) + if !errors.Is(err, agent.ErrUnknownPermission) { + t.Errorf("second SubmitPermission err = %v, want ErrUnknownPermission", err) + } +} + +func TestSessionPermissionTimeoutDeniesInsteadOfHanging(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 150 * time.Millisecond + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_permission_timeout", "approve me", "permission"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + envs, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after permission timeout") + } + final := envs[len(envs)-1] + if final.Type != proto.TypeDone { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) + } + var done proto.DonePayload + if err := final.DecodePayload(&done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "denied for req_cc_42") { + t.Fatalf("timeout did not deny the request: %q", done.Content) + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + // Pure-image inbound (empty Prompt, non-empty Attachments) is a + // valid prompt today and must NOT be rejected. + out := make(chan proto.Envelope, 4) + _, err := claudecode.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "r0", Input: proto.TextInput("")}, + out, helperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt + no attachments") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := claudecode.NewSessionForTest(context.Background(), + helperReq("r0", "hi", "echo-success"), + nil, helperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := helperConfig() + cfg.ClaudeBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := claudecode.NewSessionForTest(context.Background(), + helperReq("r0", "hi", "echo-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func envTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, e := range envs { + out[i] = e.Type + } + return out +} + +func mustContain(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Errorf("expected %q in %v", needle, haystack) + } +} + +// TestSessionAskUserQuestionRoundTrip drives the full intercept → +// answer → tool_result loop through a real subprocess, so the test +// also catches stdin write / NDJSON-framing regressions the unit +// tests can't see. +func TestSessionAskUserQuestionRoundTrip(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 30 * time.Second + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_a", "ask me", "ask-question"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + askID := "" + deadline := time.After(5 * time.Second) + var collected []proto.Envelope + for askID == "" { + select { + case env, ok := <-out: + if !ok { + t.Fatalf("out closed before prompt_for_user_choice; collected %d", len(collected)) + } + collected = append(collected, env) + if env.Type == proto.TypePromptForUserChoice { + // env.ID is the run id; the ask id rides on the payload. + var p proto.PromptForUserChoicePayload + if err := env.DecodePayload(&p); err != nil { + t.Fatalf("decode prompt_for_user_choice payload: %v", err) + } + askID = p.AskID + } + case <-deadline: + t.Fatalf("timeout waiting for prompt_for_user_choice; collected %d", len(collected)) + } + } + if !strings.HasPrefix(askID, "ask_") { + t.Errorf("ask id wrong shape: %q", askID) + } + + if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"wrong"}}}, + }); err == nil { + t.Fatal("accepted a foreign question ID") + } + if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm delete"}}}, + }); err != nil { + t.Fatalf("SubmitPromptForUserChoice: %v", err) + } + + rest, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after ask answer") + } + all := append(collected, rest...) + final := all[len(all)-1] + if final.Type != "done" { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(all)) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "Confirm delete") { + t.Errorf("answer not echoed back through the fake claude loop: %q", done.Content) + } + + // Second submit must look unknown. + err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"x"}}}}) + if !errors.Is(err, agent.ErrUnknownAsk) { + t.Errorf("second SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) + } +} + +// TestSessionAskUserQuestionTimeoutSubmitsCancelled exercises the +// daemon-side timer. AskTimeout is set short so the watchdog fires +// before the human responds; the test then asserts the fake claude +// resumed with the canned "timeout" message (i.e. the timer wrote a +// successful tool_result, not an error). +func TestSessionAskUserQuestionTimeoutSubmitsCancelled(t *testing.T) { + out := make(chan proto.Envelope, 32) + cfg := helperConfig() + cfg.AskTimeout = 150 * time.Millisecond + sess, err := claudecode.NewSessionForTest(context.Background(), + helperReq("run_to", "ask me", "ask-question"), out, cfg) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + envs, closed := drain(t, out, 5*time.Second) + if !closed { + t.Fatal("out did not close after timer fired") + } + final := envs[len(envs)-1] + if final.Type != "done" { + t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) + } + var done struct { + Content string `json:"content"` + } + if err := json.Unmarshal(final.Payload, &done); err != nil { + t.Fatalf("decode done: %v", err) + } + if !strings.Contains(done.Content, "10 minutes") { + t.Errorf("timeout sentence not echoed: %q", done.Content) + } +} diff --git a/apps/daemon/internal/agent/claudecode/skills.go b/apps/daemon/internal/agent/claudecode/skills.go new file mode 100644 index 000000000..374d61f64 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/skills.go @@ -0,0 +1,276 @@ +package claudecode + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/google/uuid" +) + +// skillDescriptor is the daemon-side view of one server-sent skill entry +// under agent_options["skills"]. Wire-identical to pluginDescriptor. +type skillDescriptor struct { + Name string + Version string + DownloadURL string + SHA256 string +} + +// SkillInstallResult carries installed directories and warnings. Claude Code +// auto-scans its project root; other adapters register the returned root. +type SkillInstallResult struct { + SkillDirs []string + Warnings []string +} + +// installSkills materialises every skill under +// /.claude/skills//. Pipeline mirrors installPlugins; +// only the target subdir differs (Claude Code auto-registers skills +// from that path). +func installSkills( + ctx context.Context, + logger *slog.Logger, + workDir string, + skills []skillDescriptor, +) (SkillInstallResult, error) { + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + if strings.TrimSpace(workDir) == "" { + return SkillInstallResult{}, errors.New("claudecode skills: workDir is required") + } + return installSkillsAtRoot(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills, "claudecode skills") +} + +// InstallManagedSkills decodes the portable agent_options["skills"] payload, +// materializes it below root, and removes entries that are no longer active. +func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { + if strings.TrimSpace(root) == "" { + return SkillInstallResult{}, errors.New("managed skills: root is required") + } + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return SkillInstallResult{}, err + } + defer unlock() + skills, decodeWarnings := decodeSkillDescriptors(raw) + result, err := installSkillsAtRootLocked(ctx, logger, root, skills, "managed skills") + result.Warnings = append(decodeWarnings, result.Warnings...) + if err != nil { + return result, err + } + if err := pruneManagedSkills(root, result.SkillDirs); err != nil { + return result, err + } + return result, nil +} + +func installSkillsAtRoot( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, + logLabel string, +) (SkillInstallResult, error) { + unlock, err := installroot.Lock(ctx, root) + if err != nil { + return SkillInstallResult{}, err + } + defer unlock() + return installSkillsAtRootLocked(ctx, logger, root, skills, logLabel) +} + +func installSkillsAtRootLocked( + ctx context.Context, + logger *slog.Logger, + root string, + skills []skillDescriptor, + logLabel string, +) (SkillInstallResult, error) { + if logger == nil { + logger = obslog.Bg() + } + if len(skills) == 0 { + return SkillInstallResult{}, nil + } + + result := SkillInstallResult{} + for _, s := range skills { + if err := s.validate(); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) + logger.Warn(logLabel+": invalid descriptor", "err", err.Error()) + continue + } + + dir := filepath.Join(root, s.Name) + cacheKey := filepath.Join(dir, ".cache-key") + expectedKey := s.cacheKey() + + if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { + logger.Info(logLabel+": cache hit", + "name", s.Name, "version", s.Version, "dir", dir) + result.SkillDirs = append(result.SkillDirs, dir) + continue + } + + // Same timeout / cap as plugins — they share the install pipeline. + perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) + err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s, logLabel) + cancel() + if err != nil { + result.Warnings = append(result.Warnings, + fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) + logger.Warn(logLabel+": install failed", + "name", s.Name, "version", s.Version, "err", err.Error()) + continue + } + result.SkillDirs = append(result.SkillDirs, dir) + logger.Info(logLabel+": installed", + "name", s.Name, "version", s.Version, "dir", dir) + } + return result, nil +} + +func pruneManagedSkills(root string, activeDirs []string) error { + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("managed skills: read root %s: %w", root, err) + } + active := make(map[string]struct{}, len(activeDirs)) + for _, dir := range activeDirs { + active[filepath.Base(dir)] = struct{}{} + } + for _, entry := range entries { + if entry.Name() == ".tmp" { + continue + } + if _, ok := active[entry.Name()]; ok { + continue + } + path := filepath.Join(root, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("managed skills: remove stale entry %s: %w", path, err) + } + } + return nil +} + +// installOneSkill: same shape as installOnePlugin, only target dir differs. +// Reuses fetchPluginZip / verifyPluginSHA256FromFD / extractPluginZipFromFD +// — the helpers are skill-agnostic and applying them to skill zips keeps +// the path-traversal / TOCTOU / SHA256 defences identical. +func installOneSkill( + ctx context.Context, + logger *slog.Logger, + root, dir, cacheKey, expectedKey string, + s skillDescriptor, + logLabel string, +) error { + tmpDir := filepath.Join(root, ".tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("mkdir tmp: %w", err) + } + + zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) + defer func() { + _ = os.Remove(zipPath) + }() + + fd, err := fetchPluginZip(ctx, s.DownloadURL, zipPath) + if err != nil { + return err + } + defer fd.Close() + + if err := verifyPluginSHA256FromFD(fd, s.SHA256); err != nil { + return err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek: %w", err) + } + fi, err := fd.Stat() + if err != nil { + return fmt.Errorf("stat: %w", err) + } + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("rm old dir: %w", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir target: %w", err) + } + if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { + _ = os.RemoveAll(dir) + return err + } + + if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { + logger.Warn(logLabel+": write cache key failed", + "path", cacheKey, "err", err.Error()) + } + return nil +} + +// decodeSkillDescriptors converts agent_options["skills"] into typed +// descriptors. Mirrors decodePluginDescriptors. +func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} + } + out := make([]skillDescriptor, 0, len(items)) + warnings := make([]string, 0) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) + continue + } + s := skillDescriptor{ + Name: stringField(obj, "name"), + Version: stringField(obj, "version"), + DownloadURL: stringField(obj, "download_url"), + SHA256: stringField(obj, "sha256"), + } + if err := s.validate(); err != nil { + warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) + continue + } + out = append(out, s) + } + return out, warnings +} + +func (s skillDescriptor) validate() error { + if strings.TrimSpace(s.Name) == "" { + return errors.New("name is required") + } + if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { + return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) + } + if strings.TrimSpace(s.DownloadURL) == "" { + return errors.New("download_url is required") + } + if len(s.SHA256) != 64 { + return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) + } + return nil +} + +func (s skillDescriptor) cacheKey() string { + return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills_test.go b/apps/daemon/internal/agent/claudecode/skills_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudecode/skills_test.go rename to apps/daemon/internal/agent/claudecode/skills_test.go diff --git a/apps/daemon/internal/agent/claudecode/version.go b/apps/daemon/internal/agent/claudecode/version.go new file mode 100644 index 000000000..751a7de1b --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/version.go @@ -0,0 +1,32 @@ +package claudecode + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" +) + +// InstallURL points to the official Claude Code install instructions. +// Surfaced by `oac-daemon connect` when the CLI is missing so the user +// has a clear next step instead of an opaque "exec: no such file". +const InstallURL = "https://docs.anthropic.com/claude/docs/claude-code" + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish +// "install Claude Code" from "Claude Code is broken". +var ErrCLINotFound = errors.New("claude CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. Empty binary defaults to binpath.ClaudeCode() — the same +// resolver the session spawn uses, so probe and spawn always agree. On +// missing binary the error wraps ErrCLINotFound; on other failures the +// wrapped error keeps the raw stderr. +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "claude", + DefaultBinary: binpath.ClaudeCode(), + MissingError: ErrCLINotFound, + }) +} diff --git a/apps/daemon/internal/agent/claudecode/version_test.go b/apps/daemon/internal/agent/claudecode/version_test.go new file mode 100644 index 000000000..3c4704075 --- /dev/null +++ b/apps/daemon/internal/agent/claudecode/version_test.go @@ -0,0 +1,17 @@ +package claudecode_test + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "claude", + DefaultBinary: "claude", + MissingError: claudecode.ErrCLINotFound, + Check: claudecode.CheckCLIAvailable, + }) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go b/apps/daemon/internal/agent/claudesdk/bridge_output.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudesdk/bridge_output.go rename to apps/daemon/internal/agent/claudesdk/bridge_output.go diff --git a/apps/daemon/internal/agent/claudesdk/cancellation.go b/apps/daemon/internal/agent/claudesdk/cancellation.go new file mode 100644 index 000000000..01d18b381 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/cancellation.go @@ -0,0 +1,67 @@ +package claudesdk + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Cancel addresses this fixed Turn. Settlement remains observable after the caller detaches. +func (s *session) Cancel(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-s.settled: + return s.settlementErr + default: + } + if err := ctx.Err(); err != nil { + return err + } + if s.owner == nil { + return errors.New("claudesdk: Turn owner is unavailable") + } + s.cancelOnce.Do(func() { + close(s.cancelOutput) + s.owner.mu.Lock() + current := s.owner.active == s && !s.owner.invalid + s.owner.mu.Unlock() + 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 { + s.invalidate() + } + } + }) + select { + case <-s.settled: + return s.settlementErr + case <-ctx.Done(): + select { + case <-s.settled: + return s.settlementErr + default: + return ctx.Err() + } + } +} + +// CancellationOutcome is available after successful Cancel or terminal publication. +// Closing settled publishes the immutable snapshot; an unsettled result is unknown. +func (s *session) CancellationOutcome() proto.DonePayload { + select { + case <-s.settled: + return s.outcome + default: + return proto.DonePayload{} + } +} + +var _ agent.Turn = (*session)(nil) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go index 5173ed076..a16a9d7ce 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go new file mode 100644 index 000000000..9e302eebf --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -0,0 +1,209 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := cancellationConfig(root, "wait") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // A stopped consumer must not prevent native output draining or cancellation. + out := make(chan proto.Envelope) + running, err := NewFactory(config)(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("missing native readiness barrier") + } + short, stop := context.WithTimeout(ctx, 50*time.Millisecond) + err = running.Cancel(short) + stop() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unsettled cancellation reported %v", err) + } + provider, ok := running.(interface{ CancellationOutcome() proto.DonePayload }) + if !ok { + t.Fatal("missing cancellation outcome provider") + } + if got := provider.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { + t.Fatal("unsettled outcome was exposed", got) + } + if err := running.(*session).Steer(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal("cancelled execution accepted steering", err) + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := running.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case <-running.(*session).process.Done(): + default: + t.Fatal("successful cancellation preceded owned process release") + } + got := provider.CancellationOutcome() + if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil || got.Usage.Tokens != nil { + t.Fatalf("lost drained cancellation outcome: %+v", got) + } + var done proto.DonePayload + for event := range out { + if event.Type == proto.TypeDone { + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + // Router cleanup calls Cancel while it is still handling Done. + if err := running.Cancel(ctx); err != nil { + t.Fatal("completion cleanup waited on terminal delivery", err) + } + } + } + gotJSON, _ := json.Marshal(got) + doneJSON, _ := json.Marshal(done) + if !bytes.Equal(gotJSON, doneJSON) { + t.Fatal("Done differs from cancellation outcome", done) + } +} + +func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { + for _, mode := range []string{"failure", "wrong-identity", "before-identity"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 8) + running, err := NewFactory(cancellationConfig(root, mode))(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + failed := false + var done proto.DonePayload + for event := range out { + if event.Type == proto.TypeError { + failed = true + } + if event.Type == proto.TypeDone { + _ = event.DecodePayload(&done) + } + } + if !failed { + t.Fatal("native failure was not reported") + } + if mode == "failure" { + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Content != "partial" || done.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("verified failure outcome was lost", done) + } + } else if done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("requested or mismatched native identity was exposed", done) + } + }) + } +} + +func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := cancellationConfig(root, "wait") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, cancellationRequest(), out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(ctx) + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("missing native readiness barrier") + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := running.Cancel(ctx); err != nil { + t.Fatal(err) + } + var text string + usage := 0 + for event := range out { + if event.Type == proto.TypeDelta { + var delta proto.DeltaPayload + if err := event.DecodePayload(&delta); err != nil { + t.Fatal(err) + } + text += delta.Delta + } + if event.Type == proto.TypeUsage { + usage++ + } + } + if text != "taildrained" || usage != 1 { + t.Fatalf("ready consumer lost drained observations: text %q, usage %d", text, usage) + } +} + +func cancellationConfig(root, mode string) Config { + return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} +} + +func cancellationRequest() proto.PromptRequestPayload { + return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} +} + +func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { + if mode == "cancellation-wait" { + emit(bridgeEvent{Type: "delta", Delta: "partial"}) + if !scanner.Scan() { + return + } + var cancel map[string]any + if json.Unmarshal(scanner.Bytes(), &cancel) != nil || cancel["type"] != "turn_cancel" { + os.Exit(9) + } + // These valid observations were in flight when cancellation started. + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) + emit(bridgeEvent{Type: "delta", Delta: "tail"}) + for { + if _, err := os.Stat(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "release")); err == nil { + break + } + time.Sleep(time.Millisecond) + } + _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) + emit(bridgeEvent{Type: "delta", Delta: "drained"}) + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + return + } + if mode != "cancellation-before-identity" { + id := request.Resume + if mode == "cancellation-wrong-identity" { + id = "wrong-session" + } + emit(bridgeEvent{Type: "input_ready", SessionID: id}) + emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: json.RawMessage(usageFixture)}) + emit(bridgeEvent{Type: "delta", Delta: "partial"}) + } + emit(bridgeEvent{Type: "error", Code: "execution_failed"}) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands.go b/apps/daemon/internal/agent/claudesdk/commands.go similarity index 96% rename from apps/parsar-daemon/internal/agent/claudesdk/commands.go rename to apps/daemon/internal/agent/claudesdk/commands.go index d7afe2f01..46dfa31b1 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands.go +++ b/apps/daemon/internal/agent/claudesdk/commands.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type commandState struct { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go rename to apps/daemon/internal/agent/claudesdk/commands_session_test.go index ebed0792c..de9454556 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_test.go b/apps/daemon/internal/agent/claudesdk/commands_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/commands_test.go rename to apps/daemon/internal/agent/claudesdk/commands_test.go index d377b20eb..33b923c7f 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func commandEvent(id, stage, status, command string) bridgeEvent { diff --git a/apps/daemon/internal/agent/claudesdk/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go new file mode 100644 index 000000000..4683fd3aa --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -0,0 +1,27 @@ +package claudesdk + +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.Steerer = (*session)(nil) + _ agent.FunctionResultSubmitter = (*session)(nil) + _ agent.PermissionResponder = (*session)(nil) + _ agent.UserChoiceResponder = (*session)(nil) + _ agent.WorkspaceReader = (*session)(nil) + _ agent.WorkspaceDirectoryLister = (*session)(nil) + _ agent.WorkspaceWriter = (*session)(nil) + _ agent.Prepared = (*prepared)(nil) + _ agent.PreparedCancellation = (*prepared)(nil) + _ agent.WorkspaceReader = (*executor)(nil) + _ agent.WorkspaceDirectoryLister = (*executor)(nil) + _ agent.WorkspaceWriter = (*executor)(nil) + _ agent.WorkspaceReader = (*prepared)(nil) + _ agent.WorkspaceDirectoryLister = (*prepared)(nil) + _ agent.WorkspaceWriter = (*prepared)(nil) +) diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go new file mode 100644 index 000000000..737d3d173 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -0,0 +1,100 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { + for _, mode := range []string{"valid", "unconfirmed", "wrong-session", "wrong-result", "success-usage", "cancelled", "unknown", "malformed-code", "after-terminal", "scanner-error", "process-error"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + s, err := NewFactory(config)(ctx, req, out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + var failure proto.ErrorPayload + var done proto.DonePayload + usage, errors, dones := 0, 0, 0 + for event := range out { + switch event.Type { + case proto.TypeUsage: + usage++ + case proto.TypeError: + errors++ + _ = event.DecodePayload(&failure) + case proto.TypeDone: + dones++ + _ = event.DecodePayload(&done) + } + } + if mode == "unconfirmed" { + if _, err := s.(*session).AwaitSettlement(ctx); err == nil { + t.Fatal("native diagnostic fabricated confirmed Turn settlement") + } + } + want := "" + if mode == "valid" || mode == "unconfirmed" { + want = "authentication_error" + } + if errors != 1 || dones != 1 || usage != 1 || failure.Code != want || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { + t.Fatalf("lost evidence: %d/%d/%d %+v %+v", errors, dones, usage, failure, done) + } + }) + } +} + +func runClassifiedFailureHelper(request startRequest, mode string, encode func(bridgeEvent)) { + mode = strings.TrimPrefix(mode, "classified-") + encode(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + raw := strings.ReplaceAll(strings.ReplaceAll(usageFixture, `"subtype":"success"`, `"subtype":"error_during_execution"`), `"is_error":false`, `"is_error":true`) + if mode == "success-usage" { + raw = usageFixture + } + encode(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: "result", Usage: json.RawMessage(raw)}) + e := bridgeEvent{Type: "error", Code: "execution_failed", SessionID: request.Resume, ResultID: "result", EngineErrorCode: json.RawMessage(`"authentication_error"`)} + switch mode { + case "wrong-session": + e.SessionID = "foreign" + case "wrong-result": + e.ResultID = "foreign" + case "cancelled": + e.Code = "cancelled" + case "unknown": + e.EngineErrorCode = json.RawMessage(`"future"`) + case "malformed-code": + e.EngineErrorCode = json.RawMessage(`{"secret":"value"}`) + } + encode(e) + if mode == "unconfirmed" { + confirmed, reusable := false, false + encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, Reusable: &reusable, Reason: "native_execution_unavailable"}) + os.Exit(0) + } + if mode == "process-error" { + os.Exit(7) + } + if mode == "after-terminal" { + fmt.Fprintln(os.Stdout, `{"type":"delta","delta":"late"}`) + } + if mode == "scanner-error" { + fmt.Fprintln(os.Stdout, strings.Repeat("x", 2*1024*1024)) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go new file mode 100644 index 000000000..4f79035ce --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -0,0 +1,124 @@ +//go:build unix + +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("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) + if err != nil { + t.Fatal(err) + } + request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + before, _ := json.Marshal(request) + controlled, _, err := prepare(config, request) + if err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(request) + if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) { + t.Fatal("default controls changed native input, instructions, continuation or caller options") + } +} + +func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { + cases := map[string]proto.ExecutionControls{ + "empty": {}, "missing-search": {TextVerbosity: "medium"}, "missing-verbosity": {WebSearch: "disabled"}, + "cached-search": {WebSearch: "cached", TextVerbosity: "medium"}, + "live-search": {WebSearch: "live", TextVerbosity: "medium"}, + "unknown-search": {WebSearch: "invalid", TextVerbosity: "medium"}, + "low-verbosity": {WebSearch: "disabled", TextVerbosity: "low"}, + "high-verbosity": {WebSearch: "disabled", TextVerbosity: "high"}, + "unknown-verbosity": {WebSearch: "disabled", TextVerbosity: "invalid"}, + } + for name, controls := range cases { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ExecutionControls: &controls, AgentOptions: map[string]any{"model": "native-model"}} + _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.Contains(err.Error(), "execution controls require") { + t.Fatal("unsupported controls did not fail at admission", err) + } + if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + t.Fatal("unsupported controls reached native setup", err) + } + }) + } +} + +func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} + _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { + t.Fatal("MCP reached an unsupported environment", err) + } + if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + t.Fatal("MCP reached native setup", err) + } +} + +func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ObserveMessages: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} + start, _, err := prepare(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." { + t.Fatal("native configuration changed") + } + request.ExecutionControls.OutputFormat.Schema = json.RawMessage(`{"type":"object","const":9007199254740993}`) + if _, _, err := prepare(config, request); err == nil { + t.Fatal("lossy schema accepted") + } + request.ExecutionControls.OutputFormat.Schema = schema + request.DisableSubagents = false + if _, _, err := prepare(config, request); err == nil { + t.Fatal("unqualified subagent combination accepted") + } +} + +func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, AgentOptions: map[string]any{"model": "model"}, FunctionTools: []proto.FunctionTool{ + {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) + if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) { + t.Fatal("function discovery changed native definitions", err) + } + request.DisableSubagents = false + if _, _, err := prepare(config, request); err == nil { + t.Fatal("unqualified combination admitted") + } + request.DisableSubagents = true + request.ToolSearch = false + if _, _, err := prepare(config, request); err == nil { + t.Fatal("deferred definitions became eager") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go new file mode 100644 index 000000000..34240d0c5 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -0,0 +1,318 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "errors" + "io" + "slices" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type executor struct { + mu sync.Mutex + base *session + start startRequest + active *session + ready chan error + done chan struct{} + invalid bool + nativeID string +} + +func NewExecutorFactory(config Config) agent.ExecutorFactory { + config.Env = slices.Clone(config.Env) + if config.Workspace != nil { + workspace := *config.Workspace + config.Workspace = &workspace + } + checked := &runtimeCheckCache{} + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, errors.New("claudesdk: Executor preparation cannot submit input") + } + start, env, err := prepareConfiguration(config, req) + if err != nil { + return nil, err + } + info, err := checked.check(ctx, config) + if err != nil { + return nil, err + } + if err = validateExecutorFeatures(info, start); err != nil { + return nil, err + } + start.Type = "executor_prepare" + base, err := launch(ctx, config, start, env) + 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 { + select { + case err = <-e.ready: + case <-ctx.Done(): + err = ctx.Err() + } + } + if err != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if closeErr := e.Close(closeCtx); closeErr != nil { + return e, errors.Join(err, closeErr) + } + return nil, err + } + return e, nil + } +} + +func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { + if info.Protocol != 3 { + return errors.New("claudesdk: Executor bridge protocol is unavailable") + } + if start.Workspace != nil && !info.supportsWorkspacePreparation() { + return errors.New("claudesdk: workspace preparation is unavailable") + } + if start.OutputFormat != nil && (!info.SupportsStructuredOutput() || start.Workspace != nil && !info.SupportsWorkspaceStructuredOutput()) { + return errors.New("claudesdk: packaged runtime does not support workspace structured output") + } + if start.Subagents != nil && !info.SupportsSubagents() { + return errors.New("claudesdk: subagent resources are unavailable") + } + if start.Workspace != nil && start.observeFunctions && !info.supportsWorkspaceCommands() { + return errors.New("claudesdk: packaged runtime does not support workspace command observations") + } + if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { + return errors.New("claudesdk: workspace functions are unavailable") + } + if servers := start.declaredMCP(); len(servers) > 0 { + for _, server := range servers { + if start.Workspace != nil && server.ServerURL != "" && !info.SupportsWorkspaceMCP() { + return errors.New("claudesdk: workspace HTTP MCP is unavailable") + } + } + if !info.SupportsHTTPMCP() { + return errors.New("claudesdk: packaged runtime does not support HTTP MCP") + } + for _, server := range servers { + if server.Required && !info.SupportsHTTPMCPRequired() { + return errors.New("claudesdk: packaged runtime does not support required HTTP MCP") + } + if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { + return errors.New("claudesdk: packaged runtime does not support authenticated HTTP MCP") + } + } + } + return nil +} + +func (e *executor) write(value any) error { + e.base.writeMu.Lock() + defer e.base.writeMu.Unlock() + return json.NewEncoder(e.base.process.Stdin).Encode(value) +} + +func (e *executor) read() { + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, e.base.process.Stderr); close(stderrDone) }() + scanner := e.base.bridgeOutput() + ready := false + readyFailure := errors.New("claudesdk: Executor readiness failed") + for scanner.Scan() { + raw := append([]byte(nil), scanner.Bytes()...) + var event bridgeEvent + if json.Unmarshal(raw, &event) != nil { + e.base.process.Cancel() + break + } + if !ready { + if event.Type != "executor_ready" || event.Protocol != 3 || event.TurnID != "" { + if event.Type == "error" { + readyFailure = bridgeFailure(event.Code) + } + e.base.process.Cancel() + break + } + ready = true + e.ready <- nil + continue + } + e.mu.Lock() + turn := e.active + valid := turn != nil && !turn.nativeEnded && event.TurnID == turn.runID + if valid && event.Type == "turn_settled" { + turn.nativeEnded = true + } + e.mu.Unlock() + if !valid { + e.base.process.Cancel() + break + } + select { + case turn.frames <- raw: + case <-e.base.process.Context().Done(): + } + } + e.base.process.Cancel() + for scanner.Scan() { + } + <-stderrDone + _ = e.base.process.Wait() + e.base.stopWorkspaceReads() + e.base.stopWorkspaceDirectories() + e.mu.Lock() + e.invalid = true + if e.active != nil { + close(e.active.frames) + } + e.mu.Unlock() + if !ready { + e.ready <- readyFailure + } + close(e.done) +} + +func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(run) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { + return nil, errors.New("claudesdk: Turn requires live context, identity, input and output") + } + e.mu.Lock() + if e.invalid || e.active != nil || e.base.process.Context().Err() != nil { + e.mu.Unlock() + return nil, errors.New("claudesdk: Executor is unavailable") + } + s := &session{owner: e, runID: run, process: e.base.process, writeMu: e.base.writeMu, frames: make(chan []byte, 64), functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{}), outputDone: make(chan struct{}), cancelOutput: make(chan struct{})} + start := e.start + start.Resume = e.nativeID + e.active = s + e.mu.Unlock() + 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}) + stopWrite() + if err != nil { + s.invalidate() + return s, errors.New("claudesdk: Turn input delivery is unknown") + } + return s, nil +} + +func (e *executor) finishTurn(s *session) { + e.mu.Lock() + if e.active != s { + e.mu.Unlock() + return + } + if native, _ := s.outcome.Metadata[proto.DoneMetaAgentSessionID].(string); native != "" { + if e.nativeID != "" && e.nativeID != native { + e.invalid = true + } else { + e.nativeID = native + } + } + if !s.turnSettlement.Reusable { + e.invalid = true + } + invalid := e.invalid + if invalid { + s.turnSettlement.Reusable = false + if s.turnSettlement.Reason == "" { + s.turnSettlement.Reason = "executor_invalidated" + } + } + e.active = nil + e.mu.Unlock() + if invalid { + e.base.process.Cancel() + <-e.done + } +} + +func (e *executor) retire() { + e.mu.Lock() + e.invalid = true + e.mu.Unlock() + e.base.process.Cancel() + <-e.done +} + +func (e *executor) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + e.mu.Lock() + e.invalid = true + active := e.active + e.mu.Unlock() + e.base.process.Cancel() + select { + case <-e.done: + if active != nil { + select { + case <-active.outputDone: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + if ctx == nil { + ctx = context.Background() + } + select { + case <-s.outputDone: + return s.turnSettlement, errors.Join(s.settlementErr, s.outputErr) + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +var _ agent.Executor = (*executor)(nil) +var _ agent.Turn = (*session)(nil) + +// A timed-out operation retains its original Turn identity. Its callback cannot +// retire a healthy successor after that operation has otherwise settled. +func (s *session) invalidate() { + if s.owner == nil { + s.process.Cancel() + return + } + s.owner.mu.Lock() + defer s.owner.mu.Unlock() + if s.owner.active == s { + s.owner.invalid = true + 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/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go rename to apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go index 631f52793..f4cb246bd 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -7,7 +7,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go rename to apps/daemon/internal/agent/claudesdk/executor_fixture_test.go diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 4d3a2aec2..25fb8fcda 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -15,8 +15,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go new file mode 100644 index 000000000..7dab4ca5c --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -0,0 +1,328 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/contracttest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPayload) { + t.Helper() + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + entry := filepath.Join(root, "worker") + if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { + t.Fatal(err) + } + return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}} +} + +func runPersistentExecutorHelper() { + root := os.Getenv("SDK_EXECUTOR_DIR") + if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { + file, _ := os.OpenFile(filepath.Join(root, "probes"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if file != nil { + _, _ = file.WriteString("probe\n") + _ = file.Close() + } + printlnReport := json.NewEncoder(os.Stdout) + _ = printlnReport.Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 3, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture"}) + return + } + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + var prepare map[string]json.RawMessage + if json.Unmarshal(scanner.Bytes(), &prepare) != nil || string(prepare["type"]) != `"executor_prepare"` || prepare["input"] != nil || prepare["run_id"] != nil { + os.Exit(4) + } + _ = os.WriteFile(filepath.Join(root, "prepared"), []byte(strconv.Itoa(os.Getpid())), 0600) + encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + if os.Getenv("SDK_EXECUTOR_MODE") == "block" { + time.Sleep(time.Hour) + return + } + var active, old string + settle := func(cancel bool) { + encode(bridgeEvent{Type: "input_closed", TurnID: active, SessionID: "native-persistent"}) + if cancel { + encode(bridgeEvent{Type: "error", TurnID: active, Code: "cancelled"}) + } else { + encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent", Text: active}) + } + confirmed, reusable, reason := true, true, "" + switch os.Getenv("SDK_EXECUTOR_MODE") { + case "unknown_cancel", "queued_cancel", "pending_function_unconfirmed": + confirmed, reusable, reason = false, false, "cancellation_unconfirmed" + case "confirmed_closed": + reusable, reason = false, "native_closed" + } + event := bridgeEvent{Type: "turn_settled", TurnID: active, Confirmed: &confirmed, Reusable: &reusable, Reason: reason} + if os.Getenv("SDK_EXECUTOR_MODE") == "missing_confirmation" { + event.Confirmed = nil + } + encode(event) + old, active = active, "" + } + for scanner.Scan() { + var command struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + InputID string `json:"input_id"` + Input proto.MessageInput `json:"input"` + } + if json.Unmarshal(scanner.Bytes(), &command) != nil { + return + } + switch command.Type { + case "turn_start": + if active != "" { + os.Exit(5) + } + active = command.TurnID + if os.Getenv("SDK_EXECUTOR_MODE") == "late" && old != "" { + encode(bridgeEvent{Type: "delta", TurnID: old, Delta: "late"}) + continue + } + encode(bridgeEvent{Type: "turn_started", TurnID: active}) + encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) + encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) + if strings.HasPrefix(os.Getenv("SDK_EXECUTOR_MODE"), "pending_function") { + encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) + } + if len(command.Input) > 0 && len(command.Input[0].Content) > 0 && command.Input[0].Content[0].Text != nil && *command.Input[0].Content[0].Text == "wait" { + continue + } + settle(false) + case "steer": + if os.Getenv("SDK_EXECUTOR_MODE") == "text_contract" { + encode(bridgeEvent{Type: "input_applied", TurnID: active, InputID: command.InputID}) + continue + } + // A full bridge write has happened, but no native input receipt exists. + encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) + case "turn_cancel": + if active == command.TurnID { + settle(true) + } + } + } +} + +func consumeExecutorTurn(t *testing.T, owner agent.Executor, id, input string) (agent.Turn, <-chan proto.Envelope) { + t.Helper() + out := make(chan proto.Envelope, 16) + turn, err := owner.StartTurn(t.Context(), id, proto.TextInput(input), out) + if err != nil || turn == nil { + t.Fatalf("StartTurn: %v", err) + } + return turn, out +} +func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope, reusable bool) { + t.Helper() + for event := range out { + if event.Type == proto.TypeDone { + var done proto.DonePayload + _ = event.DecodePayload(&done) + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-persistent" { + t.Fatal("native continuity missing", done) + } + } + } + settlement, err := turn.AwaitSettlement(t.Context()) + if err != nil || settlement.Reusable != reusable { + t.Fatalf("settlement: %+v %v", settlement, err) + } +} +func TestExecutorRetainsProcessAcrossTurnsAndCancellation(t *testing.T) { + config, req := persistentConfig(t, "") + req.AgentOptions["model_provider"] = map[string]any{ + "protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "fixture-key", + } + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + pid := owner.(*executor).base.process.Cmd.Process.Pid + first, out := consumeExecutorTurn(t, owner, "first", "hello") + awaitExecutorTurn(t, first, out, true) + next, out := consumeExecutorTurn(t, owner, "next", "wait") + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal(event.Type) + } + if err := first.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + if err := next.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, next, out, true) + third, out := consumeExecutorTurn(t, owner, "third", "hello") + awaitExecutorTurn(t, third, out, true) + if owner.(*executor).base.process.Cmd.Process.Pid != pid { + t.Fatal("native process was replaced") + } + select { + case <-owner.(*executor).base.process.Done(): + t.Fatal("native process exited between Turns") + default: + } + if err := owner.Close(t.Context()); err != nil { + t.Fatal(err) + } + +} +func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { + config, req := persistentConfig(t, "late") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + first, out := consumeExecutorTurn(t, owner, "first", "hello") + awaitExecutorTurn(t, first, out, true) + second, out := consumeExecutorTurn(t, owner, "second", "hello") + for frame := range out { + if frame.Type == proto.TypeDelta { + t.Fatal("old Turn event reached successor") + } + } + if _, err := second.AwaitSettlement(t.Context()); err == nil { + t.Fatal("unknown native outcome marked settled") + } + next := make(chan proto.Envelope, 1) + if turn, err := owner.StartTurn(t.Context(), "third", proto.TextInput("hello"), next); turn != nil || err == nil { + t.Fatal("invalid executor accepted input") + } +} +func TestExecutorCachesReadinessUntilInstalledArtifactChanges(t *testing.T) { + config, req := persistentConfig(t, "") + factory := NewExecutorFactory(config) + for range 2 { + owner, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + if err := owner.Close(t.Context()); err != nil { + t.Fatal(err) + } + } + count := func() int { + raw, _ := os.ReadFile(filepath.Join(filepath.Dir(config.Entrypoint), "probes")) + return strings.Count(string(raw), "probe\n") + } + if count() != 1 { + t.Fatal("unchanged registered runtime was reprobed") + } + time.Sleep(time.Millisecond) + if err := os.WriteFile(config.Entrypoint, []byte("version-two-changed"), 0600); err != nil { + t.Fatal(err) + } + owner, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + if count() != 2 { + t.Fatal("changed installation reused stale readiness") + } +} + +func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { + config, req := persistentConfig(t, "block") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + rejected := make(chan proto.Envelope, 1) + cancelled, stop := context.WithCancel(t.Context()) + stop() + if turn, err := owner.StartTurn(cancelled, "not-written", proto.TextInput("hello"), rejected); turn != nil || err == nil { + t.Fatal("cancelled admission reached native input") + } + select { + case <-rejected: + t.Fatal("nil Turn consumed caller output") + default: + } + e := owner.(*executor) + if err := e.base.process.Stdin.Close(); err != nil { + t.Fatal(err) + } + out := make(chan proto.Envelope, 8) + turn, err := owner.StartTurn(t.Context(), "unknown-write", proto.TextInput("hello"), out) + if turn == nil || err == nil { + t.Fatalf("attempted write must preserve unknown Turn ownership: %T %v", turn, err) + } + for range out { + } + if _, err := turn.AwaitSettlement(t.Context()); err == nil { + t.Fatal("lost input outcome became confirmed") + } +} + +func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { + config, req := persistentConfig(t, "block") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + turn, out := consumeExecutorTurn(t, owner, "blocked", "hello") + e := owner.(*executor) + written := make(chan error, 1) + go func() { written <- e.write(map[string]string{"padding": strings.Repeat("x", 2*1024*1024)}) }() + deadline := time.Now().Add(time.Second) + for e.base.writeMu.TryLock() { + e.base.writeMu.Unlock() + if time.Now().After(deadline) { + t.Fatal("transport write never started") + } + time.Sleep(time.Millisecond) + } + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) + defer cancel() + returned := make(chan error, 1) + go func() { returned <- turn.Cancel(ctx) }() + select { + case err := <-returned: + if err == nil { + t.Fatal("blocked cancellation invented settlement") + } + case <-time.After(time.Second): + t.Fatal("Cancel ignored its transport deadline") + } + <-written + for range out { + } +} + +func TestSharedTextLifecycle(t *testing.T) { + config, req := persistentConfig(t, "text_contract") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + contracttest.TextLifecycle(t, contracttest.TextFixture{ + Executor: owner, + CompleteInput: proto.TextInput("hello"), ActiveInput: proto.TextInput("wait"), SteeringInput: proto.TextInput("continue waiting"), + Ready: func(e proto.Envelope) bool { return e.Type == proto.TypeDelta }, + NativeOwner: func() string { return strconv.Itoa(owner.(*executor).base.process.Cmd.Process.Pid) }, + }) +} diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go new file mode 100644 index 000000000..d1b9ce48c --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -0,0 +1,242 @@ +package claudesdk + +import ( + "encoding/json" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "strings" + "time" +) + +func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { + defer close(s.outputDone) + defer s.owner.finishTurn(s) + defer close(out) + defer s.stopFunctions() + defer s.stopSteering() + var failure error + outputLost := false + terminalLost := false + emit := func(kind string, payload any) { + event, err := proto.NewEnvelope(kind, s.runID, payload) + if err != nil { + return + } + select { + case out <- event: + return + default: + } + var cancelled <-chan struct{} + if kind != proto.TypeDone && kind != proto.TypeError { + cancelled = s.cancelOutput + } + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case out <- event: + case <-cancelled: + outputLost = true + case <-timer.C: + outputLost = true + if kind == proto.TypeDone || kind == proto.TypeError { + terminalLost = true + } + s.invalidate() + } + } + var content strings.Builder + var result *bridgeEvent + var classifiedFailure error + var engineCode, failedResultID string + var usage proto.Usage + var usageSession string + usageIDs := map[string]bool{} + var sequence uint64 + terminal := false + mcp := mcpState{calls: map[string]proto.ToolObservation{}} + commands := commandState{calls: map[string]proto.ToolObservation{}} + settlementReceived := false + settlementConfirmed := false + cancelled := false + reusable := false + reason := "bridge_interrupted" + for raw := range s.frames { + var event bridgeEvent + if err := json.Unmarshal(raw, &event); err != nil || event.TurnID != s.runID { + failure = fmt.Errorf("claudesdk: invalid Turn event identity") + s.invalidate() + break + } + if event.Type == "turn_started" && !terminal { + continue + } + if event.Type == "turn_settled" { + if !terminal || event.Reusable == nil || event.Confirmed == nil || (*event.Reusable && !*event.Confirmed) || (!*event.Reusable && event.Reason == "") { + failure = fmt.Errorf("claudesdk: invalid Turn settlement") + s.invalidate() + break + } + settlementReceived, settlementConfirmed, reusable, reason = true, *event.Confirmed, *event.Reusable, event.Reason + break + } + if terminal { + failure = fmt.Errorf("claudesdk: invalid SDK bridge output") + s.invalidate() + break + } + switch event.Type { + case "command_observation": + if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { + failure = err + s.invalidate() + } + case "mcp_observation": + if err := mcp.receive(event, start, emit); err != nil { + failure = err + s.invalidate() + } + case "delta": + if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { + failure = fmt.Errorf("claudesdk: invalid message delta identity") + s.invalidate() + break + } + content.WriteString(event.Delta) + sequence++ + emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) + case "output_message": + message := event.Message + if !start.ObserveMessages || message == nil || message.ID == "" || + (message.Status != "in_progress" && message.Status != "completed") || + (message.Status == "completed") != (message.Text != nil) { + failure = fmt.Errorf("claudesdk: invalid message observation") + s.invalidate() + break + } + emit(proto.TypeOutputMessage, message) + case "function_call", "function_applied": + if err := s.receiveFunction(event, start, emit); err != nil { + failure = err + s.invalidate() + } + case "input_ready", "input_closed", "input_applied", "input_rejected": + if err := s.receiveInput(event, start); err != nil { + failure = err + s.invalidate() + } + case proto.TypeSubagentIdentity, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: + if start.Subagents == nil || !json.Valid(event.Fact) { + failure = fmt.Errorf("claudesdk: unrequested native child observation") + s.invalidate() + break + } + emit(event.Type, event.Fact) + case "usage": + if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || + (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { + failure = fmt.Errorf("claudesdk: invalid usage identity or duplicate result") + s.invalidate() + break + } + nextUsage, err := appendNativeUsage(usage, event.Usage) + failure = err + if failure != nil { + s.invalidate() + break + } + usage = nextUsage + usageIDs[event.ResultID] = true + usageSession = event.SessionID + failedResultID = "" + var nativeResult struct { + Subtype string `json:"subtype"` + IsError bool `json:"is_error"` + } + if json.Unmarshal(event.Usage, &nativeResult) == nil && (nativeResult.Subtype == "error_during_execution" || nativeResult.Subtype == "success" && nativeResult.IsError) { + failedResultID = event.ResultID + } + emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) + case "result": + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") + s.settlementErr = failure + s.invalidate() + } else { + result = &event + } + terminal = true + case "error": + cancelled = event.Code == "cancelled" + failure = bridgeFailure(event.Code) + if event.Code == "execution_failed" && event.SessionID != "" && s.matchesInputSession(event.SessionID) && + event.SessionID == usageSession && event.ResultID != "" && event.ResultID == failedResultID { + var code string + _ = json.Unmarshal(event.EngineErrorCode, &code) + engineCode, _ = proto.NormalizeEngineFailure(code, nil) + classifiedFailure = failure + } + terminal = true + default: + failure = fmt.Errorf("claudesdk: unknown SDK bridge event") + s.invalidate() + } + if failure != nil && !terminal { + break + } + } + if !settlementReceived && failure == nil { + failure = fmt.Errorf("claudesdk: Turn settlement is missing") + } + if result == nil && failure == nil { + failure = fmt.Errorf("claudesdk: SDK result is missing") + } + // Close result admission before deciding which unanswered calls cancellation settled. + s.stopFunctions() + if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" + } + mcp.close(start, emit) + commands.close(start, emit) + s.stopSteering() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} + if id := s.inputSessionID(); id != "" { + metadata[proto.DoneMetaAgentSessionID] = id + } + if failure == nil { + content.Reset() + content.WriteString(result.Text) + metadata[proto.DoneMetaAgentSessionID] = result.SessionID + } + s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} + // Publish the observed cancellation outcome before terminal delivery. + if s.settlementErr != nil || !settlementReceived || !settlementConfirmed || !reusable || outputLost || s.process.Context().Err() != nil { + s.owner.retire() + reusable = false + if reason == "" { + reason = "bridge_interrupted" + } + } + if !settlementReceived || !settlementConfirmed { + s.settlementErr = fmt.Errorf("claudesdk: native Turn settlement is unconfirmed") + } + close(s.settled) + if failure != nil { + // A valid Turn boundary replaces the former process-exit boundary. + // A native diagnostic does not itself confirm cancellation or reuse. + if failure != classifiedFailure || !settlementReceived { + engineCode = "" + } + emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error(), Code: engineCode}) + } + emit(proto.TypeDone, s.outcome) + if outputLost { + s.owner.retire() + reusable, reason = false, "output_delivery_interrupted" + } + if terminalLost { + s.outputErr = fmt.Errorf("claudesdk: terminal output delivery failed") + } + s.turnSettlement = agent.TurnSettlement{Reusable: reusable, Reason: reason} +} diff --git a/apps/daemon/internal/agent/claudesdk/functions.go b/apps/daemon/internal/agent/claudesdk/functions.go new file mode 100644 index 000000000..80048c70b --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/functions.go @@ -0,0 +1,185 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type pendingFunction struct { + call proto.FunctionCallPayload + result *proto.FunctionResultPayload + receipt chan error + applied bool +} + +type functionState struct { + mu sync.Mutex + calls map[string]*pendingFunction + closed bool +} + +func validateFunctions(tools []proto.FunctionTool) error { + names := map[string]bool{} + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if strings.TrimSpace(tool.Name) == "" || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return fmt.Errorf("claudesdk: functions require unique names and object-root JSON schemas") + } + names[tool.Name] = true + } + return nil +} + +func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit func(string, any)) error { + var call *proto.FunctionCallPayload + var observation *proto.ToolObservation + var receipt chan error + err := func() error { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return agent.ErrUnknownFunctionCall + } + switch event.Type { + case "function_call": + call = event.Call + declared := false + if call != nil { + for _, tool := range start.Functions { + if tool.Name == call.Name { + declared = true + break + } + } + } + if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil { + return fmt.Errorf("claudesdk: invalid function call") + } + s.functions.calls[call.CallID] = &pendingFunction{call: *call, receipt: make(chan error, 1)} + observation = &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: call.Name, Arguments: call.Arguments} + case "function_applied": + pending := s.functions.calls[event.CallID] + if pending == nil || pending.result == nil || pending.applied || pending.result.DeliveryID != event.DeliveryID { + return fmt.Errorf("claudesdk: invalid native function receipt") + } + pending.applied = true + receipt = pending.receipt + status := "completed" + if !pending.result.Success { + status = "failed" + } + observation = &proto.ToolObservation{Kind: "function", Status: status, Name: pending.call.Name, Arguments: pending.call.Arguments, Content: &pending.result.Content} + } + return nil + }() + if err != nil { + return err + } + if start.observeFunctions { + id, stage := event.CallID, "after" + if call != nil { + id, stage = call.CallID, "before" + } + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) + } + if call != nil { + emit(proto.TypeFunctionCall, call) + } else { + receipt <- nil + } + return nil +} + +func (s *session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if result.CallID == "" || result.DeliveryID == "" { + return fmt.Errorf("claudesdk: function result identities are required") + } + if err := result.ValidateContent(); err != nil { + return err + } + for _, part := range result.Content { + if part.Type == "input_image" && !result.Success { + return fmt.Errorf("claudesdk: native error results cannot retain images") + } + } + if err := (proto.MessageInput{{Content: result.Content}}).ValidateInlineImages(); err != nil { + return err + } + data, err := json.Marshal(struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + proto.FunctionResultPayload + }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) + if err != nil { + return err + } + if len(data) > 1024*1024 { + return fmt.Errorf("claudesdk: function result exceeds bridge input limit") + } + var owned proto.FunctionResultPayload + if err := json.Unmarshal(data, &owned); err != nil { + return err + } + s.functions.mu.Lock() + pending := s.functions.calls[result.CallID] + if s.functions.closed || s.process.Context().Err() != nil || pending == nil || pending.result != nil { + s.functions.mu.Unlock() + return agent.ErrUnknownFunctionCall + } + pending.result = &owned + s.functions.mu.Unlock() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + // A lost receipt has an unknown outcome; terminate this execution instead of resending. + stop := context.AfterFunc(ctx, s.invalidate) + defer stop() + s.writeMu.Lock() + if err = ctx.Err(); err == nil { + _, err = s.process.Stdin.Write(append(data, '\n')) + } + s.writeMu.Unlock() + if err != nil { + s.invalidate() + return fmt.Errorf("claudesdk: function result transport failed") + } + select { + case err := <-pending.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) functionsComplete(cancelled bool) bool { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + for _, pending := range s.functions.calls { + if !pending.applied && !(cancelled && pending.result == nil) { + return false + } + } + return true +} + +func (s *session) stopFunctions() { + s.functions.mu.Lock() + defer s.functions.mu.Unlock() + if s.functions.closed { + return + } + s.functions.closed = true + for _, pending := range s.functions.calls { + if !pending.applied { + pending.receipt <- agent.ErrUnknownFunctionCall + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go new file mode 100644 index 000000000..09f8b430a --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -0,0 +1,146 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestFunctionFactoryNativeReceipts(t *testing.T) { + for _, mode := range []string{"functions-success", "functions-wrong-receipt", "functions-no-receipt", "functions-cancel"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", ObserveToolObservations: true, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + submitter := running.(agent.FunctionResultSubmitter) + submissions := make(chan error, 2) + calls := 0 + failed := false + complete := map[string]proto.ToolObservation{} + for event := range out { + if event.ID != "run" { + t.Fatal("wrong run") + } + switch event.Type { + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := event.DecodePayload(&call); err != nil { + t.Fatal(err) + } + calls++ + invalid := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: true} + if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { + t.Fatal("missing content consumed call") + } + image := "https://example.invalid/image" + invalid.Content = []proto.InputContent{{Type: "input_image", ImageURL: &image}} + if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { + t.Fatal("image should fail before delivery") + } + first, second := "first-"+call.CallID, "second-"+call.CallID + value := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: call.CallID == "b", Content: []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} + go func() { submissions <- submitter.SubmitFunctionResult(ctx, value) }() + 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" { + t.Fatal("expected neutral observation") + } + if tool.Stage != "before" && tool.Stage != "after" { + t.Fatal("invalid shared tool stage") + } + if tool.Stage == "after" { + complete[tool.ID] = *tool.Observation + } + case proto.TypeDelta: + if mode == "functions-cancel" { + if err := running.Cancel(ctx); err == nil { + t.Fatal("unconfirmed function receipts became successful cancellation") + } + } + case proto.TypeError: + failed = true + } + } + if calls != 2 || failed != (mode != "functions-success") { + t.Fatalf("calls=%d failed=%v", calls, failed) + } + for range calls { + if err := <-submissions; (err == nil) != (mode == "functions-success") { + t.Fatalf("unexpected receipt: %v", err) + } + } + if mode == "functions-success" { + if len(complete) != 2 || complete["a"].Status != "failed" || complete["b"].Status != "completed" { + t.Fatalf("bad observations: %+v", complete) + } + for id, value := range complete { + if value.Content == nil || len(*value.Content) != 2 || *(*value.Content)[0].Text != "first-"+id || *(*value.Content)[1].Text != "second-"+id { + t.Fatal("lost ordered result") + } + } + } else if len(complete) != 0 { + t.Fatal("unconfirmed result acquired a completed observation") + } + }) + } +} + +func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { + if len(request.Functions) != 1 || request.Functions[0].Name != "lookup" || !strings.Contains(string(request.Functions[0].Parameters), `"items":{"type":"string"}`) { + os.Exit(4) + } + for _, id := range []string{"a", "b"} { + emit(bridgeEvent{Type: "function_call", Call: &proto.FunctionCallPayload{CallID: id, Name: "lookup", Arguments: json.RawMessage(`{"ids":["same"]}`)}}) + } + results := map[string]proto.FunctionResultPayload{} + for range 2 { + if !scanner.Scan() { + os.Exit(5) + } + var value proto.FunctionResultPayload + if json.Unmarshal(scanner.Bytes(), &value) != nil || value.ValidateContent() != nil || len(value.Content) != 2 { + os.Exit(6) + } + results[value.CallID] = value + } + if mode == "functions-cancel" { + emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } + return + } + if mode == "functions-no-receipt" { + return + } + for _, id := range []string{"b", "a"} { + delivery := results[id].DeliveryID + if mode == "functions-wrong-receipt" { + delivery = "wrong" + } + emit(bridgeEvent{Type: "function_applied", CallID: id, DeliveryID: delivery}) + } + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) +} diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go new file mode 100644 index 000000000..cf5dbd5a0 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -0,0 +1,41 @@ +package claudesdk + +import ( + "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "path/filepath" + "testing" +) + +func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + native := map[string]any{"effort": "high", "thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} + req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}} + start, _, err := prepareConfiguration(config, req) + if err != nil { + t.Fatal(err) + } + native["thinking"].(map[string]any)["budgetTokens"] = 2048 + raw, err := json.Marshal(start) + if err != nil { + t.Fatal(err) + } + var bridge map[string]any + if json.Unmarshal(raw, &bridge) != nil { + t.Fatal("invalid bridge request") + } + if _, exists := bridge["harness_config"]; exists { + t.Fatal("public configuration crossed the private bridge") + } + applied := bridge["native_model_options"].(map[string]any) + if applied["effort"] != "high" || applied["thinking"].(map[string]any)["budgetTokens"] != float64(1024) { + t.Fatal("bridge lost immutable native configuration") + } + req.AgentOptions["harness_config"] = map[string]any{"env": map[string]any{"ANTHROPIC_BASE_URL": "bypass"}} + if _, _, err = prepareConfiguration(config, req); err != harnessconfig.ErrHarnessConfig { + t.Fatalf("provider override accepted: %v", err) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/installation.go b/apps/daemon/internal/agent/claudesdk/installation.go new file mode 100644 index 000000000..2bc814f6a --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/installation.go @@ -0,0 +1,23 @@ +package claudesdk + +import ( + "context" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "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" }, + 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} + }, + 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() { + return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/live_linux_test.go index aa8511c0e..335ff6ecd 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -21,7 +21,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/daemon/internal/agent/claudesdk/local.go similarity index 95% rename from apps/parsar-daemon/internal/agent/claudesdk/local.go rename to apps/daemon/internal/agent/claudesdk/local.go index 8c4e043f5..d3340f3f9 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/daemon/internal/agent/claudesdk/local.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/local_test.go rename to apps/daemon/internal/agent/claudesdk/local_test.go index e69a81a6a..05a1729ce 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go new file mode 100644 index 000000000..a74da05d7 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -0,0 +1,138 @@ +package claudesdk + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +var mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) + +func validateMCP(req proto.PromptRequestPayload) error { + if req.MCPHTTPServers == nil { + return nil + } + if err := validateMCPServers(*req.MCPHTTPServers); err != nil { + return err + } + _, err := agent.ResolveMCPBindings(req) + return err +} + +func validateMCPServers(servers []proto.MCPHTTPServer) error { + labels := map[string]bool{} + for _, server := range servers { + endpoint, err := url.Parse(server.ServerURL) + if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || + err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || + strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { + return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") + } + if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { + return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") + } + labels[server.ServerLabel] = true + if server.AllowedTools != nil { + for _, name := range *server.AllowedTools { + if !mcpTool.MatchString(name) { + return fmt.Errorf("claudesdk: unsupported MCP tool allowlist") + } + } + } + } + return nil +} + +// Only generated references cross the private bridge; secrets stay in the owned +// process environment and are expanded by the native HTTP client. +type mcpHTTPServer struct { + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url,omitempty"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` +} + +func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { + if declarations == nil { + return nil, nil + } + servers := make([]mcpHTTPServer, len(*declarations)) + var env []string + for i, declaration := range *declarations { + server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} + if declaration.AllowedTools != nil { + tools := append([]string{}, (*declaration.AllowedTools)...) + server.AllowedTools = &tools + } + if declaration.BearerToken != nil { + server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() + env = append(env, server.BearerTokenEnvVar+"="+*declaration.BearerToken) + } + servers[i] = server + } + return &servers, env +} + +type mcpState struct { + calls map[string]proto.ToolObservation +} + +func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(string, any)) error { + n := event.Observation + if n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) || + !json.Valid(n.Output) || !json.Valid(n.Error) { + return fmt.Errorf("claudesdk: invalid MCP observation") + } + declared := false + for _, server := range start.declaredMCP() { + if server.ServerLabel == n.Server && n.Name != "" && (server.AllowedTools == nil || slices.Contains(*server.AllowedTools, n.Name)) { + declared = true + break + } + } + previous, exists := m.calls[event.ID] + if !declared || (event.Stage == "before" && (exists || n.Status != "in_progress")) || + (event.Stage == "after" && (!exists || previous.Status != "in_progress" || + (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete") || + previous.Server != n.Server || previous.Name != n.Name || !bytes.Equal(previous.Arguments, n.Arguments))) || + (event.Stage != "before" && event.Stage != "after") { + return fmt.Errorf("claudesdk: inconsistent MCP observation") + } + m.calls[event.ID] = *n + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) + } + return nil +} + +func (m *mcpState) complete() bool { + for _, call := range m.calls { + if call.Status == "in_progress" { + return false + } + } + return true +} + +func (m *mcpState) close(start startRequest, emit func(string, any)) { + for id, call := range m.calls { + if call.Status != "in_progress" { + continue + } + call.Status = "incomplete" + m.calls[id] = call + if start.observeFunctions { + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go rename to apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 8212ba2b9..21997b158 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go similarity index 91% rename from apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go rename to apps/daemon/internal/agent/claudesdk/mcp_environment.go index 082941cb4..5c9e1db31 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -3,9 +3,9 @@ package claudesdk import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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" ) // Environment servers originate in frozen installed packages. Only native diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go new file mode 100644 index 000000000..8cae72bf1 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -0,0 +1,92 @@ +package claudesdk + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" +) + +func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "enabled" + req := workspaceRequest() + token := "selected-user-token" + t.Setenv("MCP_TOKEN", "unselected-native-token") + req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", NetworkAccess: "enabled", 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}, + }} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 || start.Workspace.CapabilityRoot != req.LocalEnvironment.CapabilityRoot { + t.Fatal("environment declarations changed authority") + } + stdio := start.Workspace.MCP[0] + executable, _ := os.Executable() + if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { + t.Fatal("stdio bypassed the shared installed entry") + } + raw, _ := json.Marshal(start) + for _, forbidden := range []string{token, "unselected-native-token", "untrusted-package-command", "package-argument", `"MCP_TOKEN"`} { + if strings.Contains(string(raw), forbidden) { + t.Fatal("private request contains package input or credential values") + } + } + reference := start.Workspace.MCP[1].BearerTokenEnvVar + found := false + for _, entry := range env { + if entry == reference+"="+token { + found = true + } + } + if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { + t.Fatal("selected credential or observation declarations missing") + } +} + +func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { + for _, mutate := range []func(*proto.LocalEnvironment){ + func(e *proto.LocalEnvironment) { e.NetworkAccess = "restricted" }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" }, + func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, + func(e *proto.LocalEnvironment) { + token := "token" + e.MCP[0].BearerToken = &token + e.MCP[0].Server.URL = "http://example.invalid/mcp" + }, + } { + environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} + mutate(environment) + if _, _, err := prepareRuntimeMCP(proto.PromptRequestPayload{LocalEnvironment: environment}); err == nil { + t.Fatal("unsupported declaration accepted") + } + } +} + +func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { + start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true} + state := mcpState{calls: map[string]proto.ToolObservation{}} + observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)} + var emitted []proto.ToolCallPayload + emit := func(_ string, payload any) { emitted = append(emitted, payload.(proto.ToolCallPayload)) } + if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil { + t.Fatal(err) + } + state.close(start, emit) + if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" { + t.Fatal("interrupted environment call lost identity") + } + observation.Server = "undeclared" + if err := state.receive(bridgeEvent{ID: "other", Stage: "before", Observation: &observation}, start, emit); err == nil { + t.Fatal("undeclared observation accepted") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go new file mode 100644 index 000000000..7d1220a01 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -0,0 +1,117 @@ +package claudesdk + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestHTTPMCPDeclaration(t *testing.T) { + for _, mode := range []string{"unrestricted", "selected", "empty", "nil-slice", "required", "auth", "url-auth", "query", "wildcard", "reserved", "duplicate", "environment"} { + t.Run(mode, func(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")} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} + req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} + tools := []string{"echo"} + switch mode { + case "selected": + servers[0].AllowedTools = &tools + case "empty": + tools = []string{} + servers[0].AllowedTools = &tools + case "nil-slice": + tools = nil + servers[0].AllowedTools = &tools + case "required": + servers[0].Required = true + case "auth": + token := "private" + servers[0].BearerToken = &token + case "url-auth": + servers[0].ServerURL = "https://user:secret@example.invalid/mcp" + case "query": + servers[0].ServerURL += "?" + case "wildcard": + tools = []string{"*"} + servers[0].AllowedTools = &tools + case "reserved": + servers[0].ServerLabel = "functions" + case "duplicate": + servers = append(servers, servers[0]) + case "environment": + req.DisableExecutionEnvironment = false + } + start, _, err := prepare(config, req) + valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" + if (err == nil) != valid { + t.Fatalf("unexpected admission: %v", err) + } + if !valid { + return + } + raw, _ := json.Marshal(start) + if mode == "required" && !strings.Contains(string(raw), `"required":true`) { + t.Fatal("required initialization was discarded") + } + if (mode == "empty" || mode == "nil-slice") && !strings.Contains(string(raw), `"allowed_tools":[]`) { + t.Fatal("empty selection widened") + } + if mode == "selected" { + tools[0] = "changed" + if (*(*start.MCPHTTPServers)[0].AllowedTools)[0] != "echo" { + t.Fatal("request did not snapshot tool selection") + } + } + }) + } +} + +func TestMCPObservationLifecycle(t *testing.T) { + start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}, observeFunctions: true} + state := mcpState{calls: map[string]proto.ToolObservation{}} + var observations []proto.ToolCallPayload + emit := func(kind string, payload any) { + if kind != proto.TypeToolCall { + t.Fatal(kind) + } + observations = append(observations, payload.(proto.ToolCallPayload)) + } + before := bridgeEvent{Type: "mcp_observation", ID: "native", Stage: "before", Observation: &proto.ToolObservation{Kind: "mcp", Status: "in_progress", Name: "echo", Server: "fixture", Arguments: json.RawMessage(`{"value":7}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)}} + if err := state.receive(before, start, emit); err != nil { + t.Fatal(err) + } + if state.complete() { + t.Fatal("unfinished call completed") + } + if err := state.receive(before, start, emit); err == nil { + t.Fatal("duplicate call accepted") + } + after := before + after.Stage = "after" + n := *before.Observation + after.Observation = &n + n.Status = "completed" + n.Output = json.RawMessage(`{"content":"value","structuredContent":{"number":7}}`) + if err := state.receive(after, start, emit); err != nil { + t.Fatal(err) + } + if !state.complete() || string(observations[1].Observation.Output) != string(n.Output) { + t.Fatal("native result changed") + } + before.ID = "unfinished" + if err := state.receive(before, start, emit); err != nil { + t.Fatal(err) + } + state.close(start, emit) + if !state.complete() || observations[len(observations)-1].Observation.Status != "incomplete" { + t.Fatal("cancellation lost pending call") + } + if err := state.receive(after, start, emit); err == nil { + t.Fatal("terminal call reopened") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go new file mode 100644 index 000000000..c0591c668 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -0,0 +1,161 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestMessageObservations(t *testing.T) { + for _, mode := range []string{"messages-success", "messages-partial", "messages-unrequested", "messages-missing-id", "messages-invalid-snapshot"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ObserveMessages: mode != "messages-unrequested", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer running.Cancel(context.Background()) + var events []proto.Envelope + var failure bool + var done *proto.DonePayload + for event := range out { + events = append(events, event) + switch event.Type { + case proto.TypeError: + failure = true + case proto.TypeDone: + done = &proto.DonePayload{} + if err := json.Unmarshal(event.Payload, done); err != nil { + t.Fatal(err) + } + } + } + if done == nil || failure != (mode != "messages-success") { + t.Fatalf("unexpected outcome: %+v", events) + } + switch mode { + case "messages-success": + verifyMessageEvents(t, events, "partialfinal") + if done.Content != "partialfinal" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { + t.Fatalf("bad Done: %+v", done) + } + case "messages-partial": + if done.Content != "partial" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatalf("bad partial Done: %+v", done) + } + if len(events) != 4 || events[0].Type != proto.TypeOutputMessage || events[1].Type != proto.TypeDelta { + t.Fatalf("lost partial output: %+v", events) + } + case "messages-unrequested", "messages-missing-id": + if len(events) != 2 { + t.Fatalf("invalid bridge observation escaped: %+v", events) + } + } + }) + } +} + +func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) { + message := func(id, status string, text *string) { + emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: id, Status: status, Text: text}}) + } + if mode == "messages-missing-id" { + emit(bridgeEvent{Type: "delta", Delta: "unidentified"}) + return + } + if mode != "messages-unrequested" && !request.ObserveMessages { + os.Exit(4) + } + message("message-1", "in_progress", nil) + if mode == "messages-unrequested" { + return + } + emit(bridgeEvent{Type: "delta", ItemID: "message-1", Delta: "partial"}) + if mode == "messages-partial" { + emit(bridgeEvent{Type: "error", Code: "execution_failed"}) + return + } + if mode == "messages-invalid-snapshot" { + message("message-1", "completed", nil) + return + } + text := "partial" + message("message-1", "completed", &text) + message("message-2", "in_progress", nil) + emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "fi"}) + emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "nal"}) + text = "final" + message("message-2", "completed", &text) + emit(bridgeEvent{Type: "result", Text: "partialfinal", SessionID: request.Resume}) +} + +func verifyMessageEvents(t *testing.T, events []proto.Envelope, want string) { + t.Helper() + type observation struct { + text string + chunks int + completed bool + } + messages := map[string]*observation{} + var completed []string + var sequence uint64 + for _, event := range events { + switch event.Type { + case proto.TypeOutputMessage: + var value proto.OutputMessagePayload + if err := json.Unmarshal(event.Payload, &value); err != nil { + t.Fatal(err) + } + if value.ID == "" { + t.Fatal("missing message identity") + } + if value.Status == "in_progress" { + if messages[value.ID] != nil || value.Text != nil { + t.Fatal("duplicate or invalid message start") + } + messages[value.ID] = &observation{} + } else { + state := messages[value.ID] + if value.Status != "completed" || state == nil || state.completed || value.Text == nil || state.text != *value.Text { + t.Fatalf("incorrect completion snapshot: %+v, state %+v", value, state) + } + state.completed = true + completed = append(completed, *value.Text) + } + case proto.TypeDelta: + var value proto.DeltaPayload + if err := json.Unmarshal(event.Payload, &value); err != nil { + t.Fatal(err) + } + state := messages[value.ItemID] + if state == nil || state.completed || value.Sequence != sequence+1 { + t.Fatalf("unmatched/out-of-order delta: %+v", value) + } + sequence = value.Sequence + state.text += value.Delta + state.chunks++ + } + } + if len(messages) == 0 || strings.Join(completed, "") != want || sequence == 0 { + t.Fatalf("incomplete message stream: chunks=%d completed=%q want=%q", sequence, strings.Join(completed, ""), want) + } + for id, state := range messages { + if !state.completed { + t.Fatalf("message %s did not complete", id) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/model_route_test.go b/apps/daemon/internal/agent/claudesdk/model_route_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudesdk/model_route_test.go rename to apps/daemon/internal/agent/claudesdk/model_route_test.go diff --git a/apps/parsar-daemon/internal/agent/claudesdk/native_model_options.go b/apps/daemon/internal/agent/claudesdk/native_model_options.go similarity index 90% rename from apps/parsar-daemon/internal/agent/claudesdk/native_model_options.go rename to apps/daemon/internal/agent/claudesdk/native_model_options.go index ebfd03f9b..65ea8248c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/native_model_options.go +++ b/apps/daemon/internal/agent/claudesdk/native_model_options.go @@ -1,6 +1,6 @@ package claudesdk -import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" // nativeModelOptions is private SDK input, compiled only after the shared // configuration declaration validates and owns the model parameters. diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go new file mode 100644 index 000000000..a7a3e13be --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -0,0 +1,203 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" +) + +type Config struct { + Node string + Entrypoint string + StateDir string + Env []string + Workspace *WorkspaceConfig +} + +type subagentOptions struct { + MaxConcurrent int `json:"max_concurrent"` +} + +type startRequest struct { + NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"` + ToolSearch bool `json:"tool_search,omitempty"` + 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"` + Resume string `json:"resume,omitempty"` + ObserveMessages bool `json:"observe_messages,omitempty"` + Functions []proto.FunctionTool `json:"functions,omitempty"` + MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` + Workspace *workspaceProfile `json:"workspace,omitempty"` + RequireHistory bool `json:"require_history,omitempty"` + observeFunctions bool +} + +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, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} + fail := func(reason string) (startRequest, []string, error) { + return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) + } + modelConfiguration, err := harnessconfiguration.Configuration().Prepare(req.AgentOptions) + if err != nil { + return startRequest{}, nil, err + } + start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) + if req.WorkspaceAuthoring { + return fail("requested capability is not available in the private SDK adapter") + } + if err := req.ValidateToolSearch(true); err != nil { + return startRequest{}, nil, err + } + if req.ToolSearch { + if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { + return fail("tool discovery requires the single-agent text/function profile") + } + start.ToolSearch = true + } + if err := validateMCP(req); err != nil { + return startRequest{}, nil, err + } + // Search is disabled by the fixed native tool profile. Medium selects the + // SDK's default text generation; it has no native verbosity-level option. + if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { + return fail("execution controls require disabled web search and medium text verbosity") + } + if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { + format := req.ExecutionControls.OutputFormat + if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { + return fail("structured output requires the qualified message-observing single-agent function profile") + } + if err := proto.ValidateBinary64Schema(format.Schema); err != nil { + return startRequest{}, nil, err + } + start.OutputFormat = format + } + if req.ObserveSubagentIdentities { + if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { + return fail("subagent execution does not support this tool combination") + } + limit := 6 + if req.MaxConcurrentSubagents != nil { + limit = *req.MaxConcurrentSubagents + } + if limit < 1 { + return fail("invalid concurrent subagent limit") + } + start.Subagents = &subagentOptions{MaxConcurrent: limit} + } + if err := validateFunctions(req.FunctionTools); err != nil { + return startRequest{}, nil, err + } + var provider []string + for name, raw := range req.AgentOptions { + if name == "harness_config" { + continue + } + if name == "model_provider" { + var err error + provider, err = providerEnvironment(raw) + if err != nil { + return startRequest{}, nil, err + } + continue + } + if name == "system_prompt" && raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return fail("model and system_prompt options must be strings") + } + switch name { + case "model": + start.Model = value + case "system_prompt": + start.SystemPrompt = value + default: + return fail("unsupported option: " + name) + } + } + if strings.TrimSpace(start.Model) == "" { + return fail("model is required") + } + if !filepath.IsAbs(config.Entrypoint) { + return fail("SDK entrypoint must be absolute") + } + config.Env = withProvider(config.Env, provider) + if config.Workspace != nil { + profile, env, err := prepareWorkspace(config, req) + if err != nil { + return startRequest{}, nil, err + } + start.Workspace = profile + start.Cwd = workspaceCwd(config.Workspace) + return start, env, nil + } + if req.LocalEnvironment != nil || req.RequireExistingNativeSession { + return fail("local execution and history recovery require a dedicated workspace") + } + root, err := paths.Root() + if err != nil { + return startRequest{}, nil, err + } + relative, err := filepath.Rel(root, config.StateDir) + if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fail("SDK state must be in a managed runtime subdirectory") + } + start.Cwd = req.WorkDir + if start.Cwd == "" { + start.Cwd = filepath.Join(config.StateDir, "work") + } + if strings.HasPrefix(start.Cwd, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return startRequest{}, nil, err + } + start.Cwd = filepath.Join(homeDir, strings.TrimPrefix(start.Cwd, "~/")) + } + if !filepath.IsAbs(start.Cwd) { + return fail("work_dir must be absolute or start with ~/") + } + for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return startRequest{}, nil, err + } + } + env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) + env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") + projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) + if err != nil { + return startRequest{}, nil, err + } + if req.MCPHTTPServers != nil { + servers := make([]mcpHTTPServer, 0, len(projectedMCP)) + for _, server := range projectedMCP { + servers = append(servers, server.mcpHTTPServer) + } + start.MCPHTTPServers = &servers + } + env = append(env, mcpEnv...) + return start, env, nil +} diff --git a/apps/daemon/internal/agent/claudesdk/options_test.go b/apps/daemon/internal/agent/claudesdk/options_test.go new file mode 100644 index 000000000..3eb0a859f --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/options_test.go @@ -0,0 +1,34 @@ +package claudesdk + +import ( + "path/filepath" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestNullableSystemPrompt(t *testing.T) { + for _, tc := range []struct { + name string + options map[string]any + want string + reject bool + }{ + {"omitted", map[string]any{"model": "test-model"}, "", false}, + {"null", map[string]any{"model": "test-model", "system_prompt": nil}, "", false}, + {"empty", map[string]any{"model": "test-model", "system_prompt": ""}, "", false}, + {"text", map[string]any{"model": "test-model", "system_prompt": "instructions"}, "instructions", false}, + {"null-model", map[string]any{"model": nil}, "", true}, + {"null-unknown", map[string]any{"model": "test-model", "unsupported": nil}, "", true}, + } { + t.Run(tc.name, func(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}) + 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.go b/apps/daemon/internal/agent/claudesdk/preparation.go new file mode 100644 index 000000000..af9df8e01 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/preparation.go @@ -0,0 +1,116 @@ +package claudesdk + +import ( + "context" + "errors" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// prepared is a single admission for direct adapter callers. It uses the same +// Executor as pooled Runtime execution; workspace reads retain the owner. +type prepared struct { + mu sync.Mutex + executor *executor + session *session + binding *preparedStart + closed bool +} + +type preparedStart struct { + turn agent.Turn + done chan struct{} +} + +func NewPreparationFactory(config Config) agent.PreparationFactory { + factory := NewExecutorFactory(config) + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if config.Workspace == nil { + return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") + } + resource, err := factory(ctx, req) + if resource == nil { + return nil, err + } + e := resource.(*executor) + return &prepared{executor: e, session: e.base}, err + } +} + +func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + p.mu.Lock() + if p.closed || p.binding != nil { + p.mu.Unlock() + return nil, errors.New("claudesdk: admission is unavailable") + } + binding := &preparedStart{done: make(chan struct{})} + p.binding = binding + p.mu.Unlock() + turn, err := p.executor.StartTurn(ctx, run, input, out) + p.mu.Lock() + binding.turn = turn + if turn == nil { + p.binding = nil + } + close(binding.done) + p.mu.Unlock() + if turn != nil { + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() + } + return turn, err +} + +func (p *prepared) Close() error { + p.mu.Lock() + if p.binding != nil { + p.mu.Unlock() + return nil + } + p.closed = true + p.mu.Unlock() + return p.executor.Close(context.Background()) +} + +func (p *prepared) Cancel(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + p.mu.Lock() + p.closed = true + binding := p.binding + p.mu.Unlock() + if binding == nil { + return p.executor.Close(ctx) + } + select { + case <-binding.done: + default: + if err := p.executor.Close(ctx); err != nil { + return err + } + select { + case <-binding.done: + case <-ctx.Done(): + return ctx.Err() + } + } + if binding.turn == nil { + return p.executor.Close(ctx) + } + if err := binding.turn.Cancel(ctx); err != nil { + return err + } + p.executor.retire() + return nil +} + +func (p *prepared) CancellationOutcome() proto.DonePayload { + p.mu.Lock() + defer p.mu.Unlock() + if p.binding == nil || p.binding.turn == nil { + return proto.DonePayload{} + } + return p.binding.turn.CancellationOutcome() +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go rename to apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 556c00786..224a25feb 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func preparationFixture(t *testing.T, mode string) Config { diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go new file mode 100644 index 000000000..716797389 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -0,0 +1,326 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { + config := preparationFixture(t, "delayed-ready") + req := preparationRequest() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + result := make(chan agent.Prepared, 1) + failed := make(chan error, 1) + go func() { + p, err := NewPreparationFactory(config)(ctx, req) + if err != nil { + failed <- err + return + } + result <- p + }() + raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) + select { + case <-result: + t.Fatal("preparation returned before its native receipt") + case err := <-failed: + t.Fatal(err) + default: + } + if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { + t.Fatal(err) + } + var preparedResource agent.Prepared + select { + case preparedResource = <-result: + case err := <-failed: + t.Fatal(err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + p := preparedResource.(*prepared) + defer p.Cancel(context.Background()) + pid := p.session.process.Cmd.Process.Pid + if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + t.Fatal("preparation submitted input") + } + var frozen startRequest + 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 { + t.Fatal("configuration-only request was not retained") + } + config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" + config.Workspace.Directory = "/changed" + config.Workspace.Directory = "/changed" + req.AgentOptions["model"] = "changed" + req.AgentSessionID = "changed" + out := make(chan proto.Envelope, 16) + operation, stopOperation := context.WithCancel(ctx) + s, err := p.Start(operation, "actual-run", proto.TextInput("hello"), out) + stopOperation() + if err != nil { + t.Fatal(err) + } + if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { + t.Fatal("Start replaced the prepared native process") + } + if err := p.Close(); err != nil { + t.Fatal(err) + } + if _, err := p.Start(ctx, "duplicate", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("duplicate Start was accepted") + } + var done proto.DonePayload + for event := range out { + if event.ID != "actual-run" || event.Type == proto.TypeError { + t.Fatal("lost execution identity or owner lifetime", event.Type) + } + if event.Type == proto.TypeDone { + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + if done.Content != "completed" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("prepared execution lost ordinary output or frozen resume", done) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + t.Fatal("completion preceded process release", err) + } +} + +func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { + for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "none", "functions", "mcp", "controls", "old-runtime"} { + t.Run(name, func(t *testing.T) { + config := preparationFixture(t, name) + req := preparationRequest() + switch name { + case "run": + req.RunID = "unexpected" + case "prompt": + req.Input = proto.TextInput("unexpected") + case "conversation": + req.ConversationID = "product" + case "attachments": + req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} + case "authoring": + req.WorkspaceAuthoring = true + case "subagents": + req.ObserveSubagentIdentities = true + case "workspace-missing": + config.Workspace = nil + case "none": + req.DisableExecutionEnvironment = true + case "functions": + req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} + case "mcp": + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} + case "controls": + req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} + } + if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { + t.Fatal("invalid preparation was accepted") + } + if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + t.Fatal("rejection launched native preparation") + } + }) + } +} + +func TestPreparationFailureAndUnusedRelease(t *testing.T) { + for _, mode := range []string{"history-missing", "invalid-receipt", "close", "owner-cancel", "native-exit", "invalid-start", "cancelled-start"} { + t.Run(mode, func(t *testing.T) { + config := preparationFixture(t, mode) + owner, stop := context.WithCancel(t.Context()) + defer stop() + resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + if mode == "history-missing" || mode == "invalid-receipt" { + if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { + t.Fatal("preparation failure was lost", err) + } + return + } + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + switch mode { + case "close": + err = p.Close() + case "owner-cancel": + stop() + case "native-exit": + err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) + case "invalid-start", "cancelled-start": + operation, cancel := context.WithCancel(t.Context()) + prompt := "" + if mode == "cancelled-start" { + prompt = "hello" + cancel() + } + _, startErr := p.Start(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) + cancel() + if startErr == nil { + t.Fatal("invalid or cancelled Start succeeded") + } + err = p.Close() + } + if err != nil { + t.Fatal(err) + } + select { + case <-p.executor.done: + case <-time.After(5 * time.Second): + t.Fatal("unused process was not settled") + } + if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("released preparation was reusable") + } + if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { + t.Fatal("unstarted process fabricated an execution outcome", got) + } + }) + } +} + +func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { + config := preparationFixture(t, "cancellation") + owner, stop := context.WithTimeout(t.Context(), 10*time.Second) + defer stop() + resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + out := make(chan proto.Envelope) + operation, stopOperation := context.WithCancel(owner) + if _, err := p.Start(operation, "run", proto.TextInput("hello"), out); err != nil { + t.Fatal(err) + } + stopOperation() + if err := p.Close(); err != nil { + t.Fatal(err) + } + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("operation cancellation or Close cancelled the Session") + } + short, cancel := context.WithTimeout(owner, 30*time.Millisecond) + err = p.Cancel(short) + cancel() + if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { + t.Fatal("pending output drain reported settled ownership", err) + } + if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := p.Cancel(owner); err != nil { + t.Fatal(err) + } + got := p.CancellationOutcome() + if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { + t.Fatal("cancellation across transfer lost observed output", got) + } + for range out { + } +} + +func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { + for _, cancelResource := range []bool{false, true} { + for range 6 { + config := preparationFixture(t, "success") + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + out := make(chan proto.Envelope, 16) + var running agent.Session + var startErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + running, startErr = p.Start(t.Context(), "run", proto.TextInput("hello"), out) + }() + go func() { + defer wg.Done() + if cancelResource { + _ = p.Cancel(t.Context()) + } else { + _ = p.Close() + } + }() + wg.Wait() + if startErr == nil { + if running == nil { + t.Fatal("successful transfer lost its Session") + } + for range out { + } + } + if err := p.Cancel(t.Context()); err != nil { + // A racing Close can confirm resource cleanup without proving the Turn result. + if closeErr := p.executor.Close(t.Context()); closeErr != nil { + t.Fatal(closeErr) + } + } + select { + case <-p.session.process.Done(): + default: + t.Fatal("race left the owned process alive") + } + } + } +} + +func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { + config := preparationFixture(t, "success") + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + defer p.Cancel(context.Background()) + results := make(chan bool, 2) + var wg sync.WaitGroup + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + out := make(chan proto.Envelope, 16) + _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + results <- err == nil + if err == nil { + for event := range out { + if event.Type == proto.TypeError { + t.Error("losing Start cancelled the transferred Session") + } + } + } + }() + } + wg.Wait() + if first, second := <-results, <-results; first == second { + t.Fatal("concurrent Start did not produce exactly one owner") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/provider.go b/apps/daemon/internal/agent/claudesdk/provider.go new file mode 100644 index 000000000..0d78a0fe3 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/provider.go @@ -0,0 +1,30 @@ +package claudesdk + +import ( + "strings" + + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" +) + +// Validate the frozen native provider before producing launch variables. +func providerEnvironment(value any) ([]string, error) { + provider, err := harnessconfiguration.Configuration().ParseProvider(value) + if err != nil { + return nil, err + } + return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil +} + +func withProvider(env, provider []string) []string { + if provider == nil { + return env + } + out := make([]string, 0, len(env)+len(provider)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" { + out = append(out, entry) + } + } + return append(out, provider...) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/readiness.go rename to apps/daemon/internal/agent/claudesdk/readiness.go index 673725413..5c9668990 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -10,7 +10,7 @@ import ( "slices" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" ) // RuntimeInfo describes a successful local probe, not provider authentication or diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go rename to apps/daemon/internal/agent/claudesdk/readiness_test.go index dbb0af020..49b0d5260 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const readyReport = `{"type":"runtime_ready","protocol":3,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` diff --git a/apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go rename to apps/daemon/internal/agent/claudesdk/restrictions_test.go index 2b4aee46c..e0b7392de 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go b/apps/daemon/internal/agent/claudesdk/runtime_cache.go similarity index 100% rename from apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go rename to apps/daemon/internal/agent/claudesdk/runtime_cache.go diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go new file mode 100644 index 000000000..311952092 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -0,0 +1,120 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type session struct { + owner *executor + runID string + frames chan []byte + outputDone chan struct{} + turnSettlement agent.TurnSettlement + settlementErr error + outputErr error + cancelOnce sync.Once + 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 +} + +func NewFactory(config Config) agent.Factory { + prepareExecutor := NewExecutorFactory(config) + return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + run, input := req.RunID, req.Input + req.RunID, req.ConversationID, req.Input = "", "", nil + resource, err := prepareExecutor(ctx, req) + if err != nil { + return nil, err + } + turn, err := resource.StartTurn(ctx, run, input, out) + if turn == nil { + _ = resource.Close(context.Background()) + return nil, err + } + // Factory callers own one execution. Both entrypoints use the same + // Executor implementation; Runtime pooling uses NewExecutorFactory. + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = resource.Close(context.Background()) }() + return turn, err + } +} + +type bridgeEvent struct { + EngineErrorCode json.RawMessage `json:"engine_error_code"` + TurnID string `json:"turn_id"` + Reusable *bool `json:"reusable"` + Confirmed *bool `json:"confirmed"` + Reason string `json:"reason"` + Protocol int `json:"protocol"` + + Fact json.RawMessage `json:"fact"` + InputID string `json:"input_id"` + ResultID string `json:"result_id"` + Usage json.RawMessage `json:"usage,omitempty"` + Type string `json:"type"` + Delta string `json:"delta"` + SessionID string `json:"session_id"` + Text string `json:"text"` + Code string `json:"code"` + ItemID string `json:"item_id"` + Message *proto.OutputMessagePayload `json:"message"` + Call *proto.FunctionCallPayload `json:"call"` + CallID string `json:"call_id"` + DeliveryID string `json:"delivery_id"` + ID string `json:"id"` + Stage string `json:"stage"` + Observation *proto.ToolObservation `json:"observation"` +} + +func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { + binary := config.Node + if binary == "" { + binary = "node" + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + if err != nil { + return nil, err + } + return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil +} + +func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) (error, bool) { + for scanner.Scan() { + } + if scanner.Err() != nil { + failure = fmt.Errorf("claudesdk: SDK bridge output read failed") + s.process.Cancel() + } + <-stderrDone + s.stopWorkspaceReads() + s.stopWorkspaceDirectories() + waitErr := s.process.Wait() + if waitErr != nil && failure == nil { + failure = fmt.Errorf("claudesdk: SDK process failed") + } + return failure, scanner.Err() == nil && waitErr == nil +} + +func bridgeFailure(code string) error { + switch code { + case "invalid_request", "history_unavailable", "execution_failed", "cancelled": + return fmt.Errorf("claudesdk: %s", code) + default: + return fmt.Errorf("claudesdk: unknown SDK bridge failure") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go new file mode 100644 index 000000000..3dd21789d --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -0,0 +1,189 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestTextFactoryCompletionAndFailures(t *testing.T) { + for _, mode := range []string{"success", "wrong-resume", "missing", "malformed", "process-failed", "after-result", "bridge-error"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + s, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + defer s.Cancel(context.Background()) + failed := false + done := false + deltas := "" + for event := range out { + if event.ID != "run" { + t.Fatal("wrong run identity") + } + switch event.Type { + case proto.TypeDelta: + var payload proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &payload) + if payload.ItemID != "" { + t.Fatal("ordinary deltas acquired message identity") + } + deltas += payload.Delta + case proto.TypeOutputMessage: + t.Fatal("ordinary requests acquired message observations") + case proto.TypeError: + failed = true + case proto.TypeDone: + done = true + var payload proto.DonePayload + _ = json.Unmarshal(event.Payload, &payload) + if mode == "success" { + if payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { + t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) + } + if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + t.Fatal("Done preceded process release") + } + } else if payload.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("failed result exposed a successful continuity id") + } + } + } + if !done || failed != (mode != "success") { + t.Fatalf("done=%t failed=%t", done, failed) + } + }) + } +} + +func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { + for _, kind := range []string{"execution-controls", "tool", "option", "relative", "outside"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: map[string]any{"model": "fake"}} + switch kind { + case "execution-controls": + request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} + case "tool": + request.FunctionTools = []proto.FunctionTool{{}} + case "option": + request.AgentOptions["allowed_tools"] = "anything" + case "relative": + request.WorkDir = "relative" + case "outside": + config.StateDir = filepath.Dir(root) + } + _, err := NewFactory(config)(context.Background(), request, make(chan proto.Envelope, 1)) + if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { + t.Fatalf("expected pre-launch rejection, got %v", err) + } + }) + } +} + +func TestMain(m *testing.M) { + if os.Getenv("GO_CLAUDE_EXECUTOR_HELPER") == "1" { + runPersistentExecutorHelper() + os.Exit(0) + } + if os.Getenv("GO_CLAUDE_PREPARATION_HELPER") == "1" { + runPreparationHelper() + os.Exit(0) + } + if os.Getenv("GO_CLAUDE_READINESS_HELPER") == "1" { + runReadinessHelper() + os.Exit(0) + } + if os.Getenv("GO_CLAUDE_SDK_HELPER") == "1" { + runSDKHelper() + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runSDKHelper() { + if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { + fmt.Fprintln(os.Stdout, strings.TrimSuffix(readyReport, "}")+`,"features":["structured_output","subagents"]}`) + return + } + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + os.Exit(2) + } + var request startRequest + if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "executor_prepare" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { + os.Exit(3) + } + encode, finish := helperTurn(scanner, &request) + defer finish() + mode := os.Getenv("SDK_HELPER_MODE") + if strings.HasPrefix(mode, "cancellation-") { + runCancellationHelper(request, mode, scanner, encode) + return + } + if strings.HasPrefix(mode, "steering-") { + runSteeringHelper(request, mode, scanner, encode) + return + } + if strings.HasPrefix(mode, "classified-") { + runClassifiedFailureHelper(request, mode, encode) + return + } + if strings.HasPrefix(mode, "usage-") { + runUsageHelper(request, mode, encode) + return + } + if strings.HasPrefix(mode, "functions-") { + runFunctionHelper(request, mode, scanner, encode) + return + } + if strings.HasPrefix(mode, "messages-") { + runMessageHelper(request, mode, encode) + return + } + switch mode { + case "missing": + return + case "malformed": + fmt.Fprintln(os.Stdout, "malformed") + time.Sleep(time.Hour) + return + case "bridge-error": + encode(bridgeEvent{Type: "error", Code: "execution_failed"}) + return + } + encode(bridgeEvent{Type: "delta", Delta: "partial"}) + id := request.Resume + if mode == "wrong-resume" { + id = "different-session" + } + encode(bridgeEvent{Type: "result", Text: "final", SessionID: id}) + if mode == "process-failed" { + os.Exit(7) + } + if mode == "after-result" { + encode(bridgeEvent{Type: "delta", Delta: "too late"}) + return + } + time.Sleep(50 * time.Millisecond) + _ = os.WriteFile(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "released"), nil, 0o600) +} diff --git a/apps/daemon/internal/agent/claudesdk/steering.go b/apps/daemon/internal/agent/claudesdk/steering.go new file mode 100644 index 000000000..c64b55d72 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/steering.go @@ -0,0 +1,160 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type pendingInput struct { + id string + receipt chan error +} + +type steeringState struct { + mu sync.Mutex + sessionID string + closed bool + pending *pendingInput + seen map[string]bool +} + +var _ agent.Steerer = (*session)(nil) + +// Steer waits for native consumption, which may occur in a later native turn +// within this one SDK query. A completed stdin write is not a receipt. +func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, input, nil) +} + +// SteerWithReceipt separates a complete bridge write from native consumption. +func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + select { + case <-s.cancelOutput: + return agent.ErrSteeringInactive + default: + } + if ctx == nil { + ctx = context.Background() + } + 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}) + if err != nil || len(data) > 1024*1024 { + return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) + } + s.steering.mu.Lock() + if s.steering.closed || s.process.Context().Err() != nil { + s.steering.mu.Unlock() + return agent.ErrSteeringInactive + } + if s.steering.sessionID == "" { + s.steering.mu.Unlock() + return agent.ErrSteeringNotReady + } + if s.steering.pending != nil || s.steering.seen[input.InputID] || len(s.steering.seen) >= 63 { + s.steering.mu.Unlock() + return fmt.Errorf("%w: input is pending, repeated or over capacity", agent.ErrSteeringRejected) + } + if err := ctx.Err(); err != nil { + s.steering.mu.Unlock() + return err + } + if s.steering.seen == nil { + s.steering.seen = map[string]bool{} + } + pending := &pendingInput{id: input.InputID, receipt: make(chan error, 1)} + s.steering.seen[input.InputID] = true + s.steering.pending = pending + s.steering.mu.Unlock() + // Cancellation must release a blocked write, but a lost receipt after a full + // write preserves the process and unknown outcome without automatic redelivery. + stop := context.AfterFunc(ctx, s.invalidate) + s.writeMu.Lock() + if err = ctx.Err(); err == nil { + _, err = s.process.Stdin.Write(append(data, '\n')) + } + s.writeMu.Unlock() + stop() + if err != nil { + s.invalidate() + return fmt.Errorf("claudesdk: input transport failed") + } + if written != nil { + written() + } + select { + case err := <-pending.receipt: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) receiveInput(event bridgeEvent, start startRequest) error { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + switch event.Type { + case "input_ready": + if s.steering.closed || s.steering.sessionID != "" || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume { + return fmt.Errorf("claudesdk: invalid input session identity") + } + s.steering.sessionID = event.SessionID + case "input_closed": + if s.steering.closed || s.steering.sessionID == "" || event.SessionID != s.steering.sessionID { + return fmt.Errorf("claudesdk: invalid input closure") + } + s.steering.closed = true + case "input_applied", "input_rejected": + pending := s.steering.pending + if pending == nil || pending.id != event.InputID { + return fmt.Errorf("claudesdk: invalid input receipt") + } + var err error + if event.Type == "input_rejected" { + err = agent.ErrSteeringRejected + } + pending.receipt <- err + s.steering.pending = nil + } + return nil +} + +func (s *session) steeringComplete() bool { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.pending == nil +} + +func (s *session) stopSteering() { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + s.steering.closed = true + if s.steering.pending != nil { + s.steering.pending.receipt <- fmt.Errorf("claudesdk: execution ended with unknown input outcome") + s.steering.pending = nil + } +} + +func (s *session) matchesInputSession(id string) bool { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.sessionID == "" || s.steering.sessionID == id +} + +func (s *session) inputSessionID() string { + s.steering.mu.Lock() + defer s.steering.mu.Unlock() + return s.steering.sessionID +} diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go new file mode 100644 index 000000000..5f83e13d4 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -0,0 +1,213 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestSteeringReceiptsAndLifecycle(t *testing.T) { + for _, mode := range []string{"success", "phased", "timeout", "wrong-receipt", "duplicate-usage", "cancel", "blocked-write"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} + if mode == "phased" { + config.Env[1] = "SDK_HELPER_MODE=steering-timeout" + } + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + running, err := NewFactory(config)(ctx, request, out) + if err != nil { + t.Fatal(err) + } + s := running.(*session) + defer s.Cancel(context.Background()) + if frame := <-out; frame.Type != proto.TypeDelta { + t.Fatal("missing ready barrier", frame.Type) + } + input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("additional")} + if mode == "blocked-write" { + input.Input = proto.TextInput(strings.Repeat("x", 512*1024)) + } + receiptCtx, receiptCancel := context.WithTimeout(ctx, time.Second) + if mode == "timeout" { + receiptCancel() + receiptCtx, receiptCancel = context.WithTimeout(ctx, 30*time.Millisecond) + } + if mode == "blocked-write" { + receiptCancel() + receiptCtx, receiptCancel = context.WithCancel(ctx) + } + defer receiptCancel() + reply := make(chan error, 1) + go func() { + if mode == "phased" { + callCtx, cancel := context.WithCancel(ctx) + defer cancel() + timer := time.AfterFunc(30*time.Millisecond, cancel) + defer timer.Stop() + reply <- s.SteerWithReceipt(callCtx, input, func() { + if !timer.Stop() { + t.Error("write phase exceeded deadline") + } + }) + } else { + reply <- s.Steer(receiptCtx, input) + } + }() + if mode == "blocked-write" { + // Serialization can exceed a short deadline under race instrumentation. + // Cancel only after admission so this exercises transport cancellation. + for { + s.steering.mu.Lock() + admitted := s.steering.pending != nil + s.steering.mu.Unlock() + if admitted { + receiptCancel() + break + } + select { + case <-ctx.Done(): + t.Fatal("input was not admitted before test deadline") + case <-time.After(time.Millisecond): + } + } + } + if mode == "cancel" { + time.Sleep(30 * time.Millisecond) + _ = s.Cancel(context.Background()) + } + receipt := <-reply + if mode == "success" || mode == "phased" || mode == "duplicate-usage" { + if receipt != nil { + t.Fatal(receipt) + } + } else if receipt == nil { + t.Fatal("missing failure/unknown receipt") + } + if mode == "timeout" { + if !errors.Is(receipt, context.DeadlineExceeded) { + t.Fatal(receipt) + } + select { + case <-s.process.Done(): + t.Fatal("receipt timeout killed native process") + default: + } + } + var done proto.DonePayload + failed := false + measurements := 0 + for frame := range out { + switch frame.Type { + case proto.TypeError: + failed = true + case proto.TypeUsage: + measurements++ + case proto.TypeDone: + if err := frame.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + wantSuccess := mode == "success" || mode == "phased" || mode == "timeout" + if failed == wantSuccess { + t.Fatalf("unexpected terminal failure=%v", failed) + } + if wantSuccess { + if measurements != 2 || done.Content != "final" || done.Metadata[proto.DoneMetaAgentSessionID] != "native" { + t.Fatalf("bad completion: %+v, measurements=%d", done, measurements) + } + snapshots, ok := done.Usage.Raw["claude_sdk_results"].([]any) + if !ok || len(snapshots) != 2 { + t.Fatal("lost native-turn usage snapshots", done.Usage.Raw) + } + if snapshots[0].(map[string]any)["total_cost_usd"] != float64(0.1) || snapshots[1].(map[string]any)["total_cost_usd"] != float64(0.3) { + t.Fatal("native cumulative counters changed", snapshots) + } + } + if mode == "duplicate-usage" && measurements != 1 { + t.Fatal("duplicate measurement was published") + } + if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal("completed execution accepted input", err) + } + if _, err := s.AwaitSettlement(ctx); err != nil && (mode == "success" || mode == "phased" || mode == "timeout") { + t.Fatal(err) + } + if err := s.owner.Close(ctx); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestSteeringDoesNotSendBeforeReadiness(t *testing.T) { + s := &session{process: &clirunner.Process{}} + if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringNotReady) { + t.Fatal(err) + } + s.stopSteering() + if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringInactive) { + t.Fatal(err) + } +} + +func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { + emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) + emit(bridgeEvent{Type: "delta", Delta: "ready"}) + if mode == "steering-blocked-write" { + time.Sleep(time.Hour) + return + } + if !scanner.Scan() { + os.Exit(2) + } + var input struct { + Type string + Input proto.MessageInput + InputID string `json:"input_id"` + } + if json.Unmarshal(scanner.Bytes(), &input) != nil || input.Type != "steer" || *input.Input[0].Content[0].Text != "additional" || input.InputID != "extra" { + os.Exit(3) + } + if mode == "steering-cancel" { + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } + return + } + if mode == "steering-timeout" { + time.Sleep(100 * time.Millisecond) + } + if mode == "steering-wrong-receipt" { + emit(bridgeEvent{Type: "input_applied", InputID: "unknown"}) + time.Sleep(time.Hour) + return + } + emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: "first", Usage: json.RawMessage(`{"usage":{"input_tokens":4},"modelUsage":{"model":{"inputTokens":4}},"total_cost_usd":0.1}`)}) + emit(bridgeEvent{Type: "input_applied", InputID: input.InputID}) + time.Sleep(30 * time.Millisecond) + id := "second" + if mode == "steering-duplicate-usage" { + id = "first" + } + emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: id, Usage: json.RawMessage(`{"usage":{"input_tokens":7},"modelUsage":{"model":{"inputTokens":11}},"total_cost_usd":0.3}`)}) + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "final"}) +} diff --git a/apps/daemon/internal/agent/claudesdk/subagents_test.go b/apps/daemon/internal/agent/claudesdk/subagents_test.go new file mode 100644 index 000000000..6cb3e7467 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/subagents_test.go @@ -0,0 +1,48 @@ +package claudesdk + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + start, _, err := prepare(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) + 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) + limit = 4 + if err != nil || start.Subagents.MaxConcurrent != 2 { + t.Fatal("subagent configuration was not frozen", err) + } +} + +func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { + config := workspaceFixture(t) + for _, change := range []func(*proto.PromptRequestPayload){ + func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, + func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, + func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "function"}} }, + func(r *proto.PromptRequestPayload) { v := []proto.MCPHTTPServer{}; r.MCPHTTPServers = &v }, + } { + req := workspaceRequest() + req.DisableSubagents, req.ObserveSubagentIdentities = false, true + change(&req) + if _, _, err := prepare(config, req); err == nil { + t.Fatal("unqualified subagent combination accepted") + } + } + if (RuntimeInfo{}).SupportsSubagents() || !(RuntimeInfo{Features: []string{"subagent_resources"}}).SupportsSubagents() { + t.Fatal("subagent readiness did not require the packaged feature") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go new file mode 100644 index 000000000..50a1046d6 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go @@ -0,0 +1,34 @@ +package claudesdk + +import ( + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestSelfHostedToolEnvironment(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "enabled" + file := filepath.Join(t.TempDir(), "tool-env.json") + if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled"} + profile, _, err := prepareWorkspace(config, req) + if err != nil { + t.Fatal(err) + } + if profile.ToolEnv["USER_VALUE"] != "ready" { + t.Fatal("self-hosted tool configuration was not applied") + } + if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := prepareWorkspace(config, req); err == nil { + t.Fatal("invalid explicit tool configuration was ignored") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported.go b/apps/daemon/internal/agent/claudesdk/unsupported.go new file mode 100644 index 000000000..091e6d167 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/unsupported.go @@ -0,0 +1,28 @@ +package claudesdk + +import ( + "context" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (s *session) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return fmt.Errorf("%w: native permission responses are not exposed", agent.ErrUnsupportedOperation) +} + +func (s *session) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return fmt.Errorf("%w: native user-choice responses are not exposed", agent.ErrUnsupportedOperation) +} + +func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported +} + +func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported +} + +func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported +} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported_test.go b/apps/daemon/internal/agent/claudesdk/unsupported_test.go new file mode 100644 index 000000000..82ee6cae2 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/unsupported_test.go @@ -0,0 +1,38 @@ +package claudesdk + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Nil concrete receivers prove Unsupported needs no native owner, transport, +// 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") + } + } + var turn *session + check(turn.SubmitPermission(ctx, secret, proto.PermissionDecisionPayload{})) + check(turn.SubmitPromptForUserChoice(ctx, secret, proto.PromptForUserChoiceDecisionPayload{})) + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil), (*prepared)(nil)} { + 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.go b/apps/daemon/internal/agent/claudesdk/usage.go new file mode 100644 index 000000000..960cc2af2 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/usage.go @@ -0,0 +1,37 @@ +package claudesdk + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func nativeUsage(raw json.RawMessage) (proto.Usage, error) { + var snapshot map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&snapshot); err != nil || snapshot == nil { + return proto.Usage{}, fmt.Errorf("claudesdk: invalid native usage snapshot") + } + // The SDK owns native counter scopes and cost estimates. Do not expose an + // incomplete public token breakdown or a model selected from an unordered map. + return proto.Usage{Provider: "claude_code", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil +} + +// Keep every native measurement when a query spans multiple native turns. Each +// main-loop usage is per native turn; modelUsage and cost are cumulative snapshots. +func appendNativeUsage(previous proto.Usage, raw json.RawMessage) (proto.Usage, error) { + current, err := nativeUsage(raw) + if err != nil || previous.Raw == nil { + return current, err + } + snapshots, ok := previous.Raw["claude_sdk_results"].([]any) + if !ok { + snapshots = []any{previous.Raw["claude_sdk_result"]} + } + retained := append([]any{}, snapshots...) + current.Raw["claude_sdk_results"] = append(retained, current.Raw["claude_sdk_result"]) + return current, nil +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/usage_test.go rename to apps/daemon/internal/agent/claudesdk/usage_test.go index a7ddf7b37..fa9d3a650 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const usageFixture = `{"subtype":"success","is_error":false,"usage":{"input_tokens":12,"output_tokens":4},"modelUsage":{"first":{"inputTokens":12,"outputTokens":4,"costUSD":0.1,"costBasis":"unknown"},"second":{"inputTokens":25,"outputTokens":5}},"total_cost_usd":0.1}` diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go new file mode 100644 index 000000000..3e8586679 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -0,0 +1,151 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" +) + +// WorkspaceConfig binds one trusted private placement. It does not create an +// isolation boundary or authorize a public Environment. The operator must place +// the entire factory inside the qualified outer mount/process boundary first. +// State directories must already exist. +// PublicDirectory may name a second mount of the same workspace inode. +type WorkspaceConfig struct { + Directory string + PublicDirectory string + NetworkAccess string + AllowedDomains []string + HomeDir string + ScratchDir string +} + +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"` + NetworkAccess string `json:"network_access,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` +} + +func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { + if req.DisableExecutionEnvironment { + return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") + } + if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { + return nil, nil, fmt.Errorf("claudesdk: work_dir conflicts with the trusted workspace binding") + } + if req.LocalEnvironment != nil && !(agentnetwork.Policy{Access: config.Workspace.NetworkAccess, AllowedDomains: config.Workspace.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) { + return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") + } + profile, env, err := workspaceEnvironment(config) + if err != nil { + return nil, nil, err + } + if req.LocalEnvironment != nil { + profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() + if err != nil { + return nil, nil, err + } + } + + if req.LocalEnvironment != nil { + profile.Skills = req.LocalEnvironment.Skills + profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot + } + servers, credentials, err := prepareRuntimeMCP(req) + if err != nil { + return nil, nil, err + } + profile.MCP = servers + return profile, append(env, credentials...), nil +} + +func workspaceCwd(w *WorkspaceConfig) string { + if w.PublicDirectory != "" { + return w.PublicDirectory + } + return w.Directory +} + +// Harness state paths and selected model credentials overlay the user environment. +func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { + fail := func() (*workspaceProfile, []string, error) { + return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") + } + w := config.Workspace + if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { + return fail() + } + for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { + info, err := os.Stat(path) + if !filepath.IsAbs(path) || err != nil || !info.Mode().IsRegular() { + return fail() + } + } + for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { + if !canonicalWorkspaceDir(path) { + return fail() + } + } + if w.PublicDirectory != "" { + actual, err := os.Stat(w.Directory) + alias, aliasErr := os.Stat(w.PublicDirectory) + if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { + return fail() + } + } + profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + env := []string{} + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { + env = append(env, entry) + } + } + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") + seen := map[string]bool{} + for _, entry := range config.Env { + name, _, ok := strings.Cut(entry, "=") + if !ok || seen[name] || strings.ContainsRune(entry, '\x00') || !workspaceEnvName(name) { + return fail() + } + seen[name] = true + profile.EnvNames = append(profile.EnvNames, name) + env = append(env, entry) + } + return profile, env, nil +} + +func workspaceEnvName(name string) bool { + switch name { + case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + return true + default: + return false + } +} + +func canonicalWorkspaceDir(dir string) bool { + if !workspacePathSyntax(dir) { + return false + } + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} +func workspacePathSyntax(dir string) bool { + return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go index c08947b4a..b2ff0fabe 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go @@ -5,7 +5,7 @@ package claudesdk import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func liveWorkspaceCommands(t *testing.T, runID string, events []proto.Envelope, commands []string) []proto.ToolCallPayload { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go rename to apps/daemon/internal/agent/claudesdk/workspace_directory.go index 4cbf89803..5b4150cba 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go index b681ad45f..d6afad379 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory_live_linux_test.go @@ -11,7 +11,7 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) func liveWorkspaceDirectoryFixtures(t *testing.T, root string) { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go new file mode 100644 index 000000000..1c052d736 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -0,0 +1,220 @@ +//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 directoryPreparation(t *testing.T, mode string) (*prepared, Config) { + t.Helper() + config := preparationFixture(t, mode) + resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + t.Cleanup(func() { _ = p.Cancel(context.Background()) }) + return p, config +} + +func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { + p, _ := directoryPreparation(t, "directory-normal") + for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { + if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + t.Fatalf("accepted %q: %v", path, err) + } + } + for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { + if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { + t.Fatal(limit, err) + } + } + result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) + if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { + t.Fatal(result, err) + } + empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) + if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { + t.Fatal(empty, err) + } + for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { + if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { + t.Fatal(path, err) + } + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { + p, config := directoryPreparation(t, "directory-held") + ctx, detach := context.WithCancel(t.Context()) + defer detach() + result := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + detach() + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + t.Fatal(err) + } + out := make(chan proto.Envelope, 16) + running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + if err != nil { + t.Fatal(err) + } + if p.Close() != nil { + t.Fatal("transferred Close failed") + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + select { + case err := <-result: + t.Fatal("caller detach discarded native wait", err) + 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) { + p, _ := directoryPreparation(t, "directory-normal") + result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) + if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { + t.Fatal(result, err) + } + if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + t.Fatal(err) + } + }) + } + for _, mode := range []string{"directory-held", "directory-exit"} { + t.Run(mode, func(t *testing.T) { + p, config := directoryPreparation(t, mode) + done := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + if mode == "directory-held" { + if err := p.Close(); err != nil { + 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) { + p, config := directoryPreparation(t, "directory-held") + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() + waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) + if err := <-done; err != agent.ErrWorkspaceReadUncertain { + t.Fatal(err) + } + if p.session.process.Context().Err() == nil { + t.Fatal("uncertain deadline returned before owner cancellation") + } + if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Start") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index d24d0211c..dfaac5162 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceLaunchAndReadinessInheritsUserEnvironment(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index c55a0211e..6a19b6e53 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go new file mode 100644 index 000000000..9d8f4a568 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/workspace_read.go @@ -0,0 +1,211 @@ +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 = (*prepared)(nil) +var _ agent.WorkspaceReader = (*session)(nil) + +func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + p.mu.Lock() + if p.closed || p.binding != nil { + p.mu.Unlock() + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable + } + read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) + p.mu.Unlock() + if err != nil { + return agent.WorkspaceReadResult{}, err + } + return p.session.awaitWorkspaceRead(ctx, read) +} + +func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + if s.owner != nil { + return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) + } + 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/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go index 8f8a02b1f..e721126e7 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read_live_linux_test.go @@ -11,7 +11,7 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) type liveWorkspaceRead struct { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_read_test.go index d9bfa2221..083c2387a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go @@ -15,8 +15,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index f555fd75b..357eb8127 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go rename to apps/daemon/internal/agent/claudesdk/workspace_test.go index cf59fc099..01577bf43 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func workspaceFixture(t *testing.T) Config { diff --git a/apps/parsar-daemon/internal/agent/clirunner/process.go b/apps/daemon/internal/agent/clirunner/process.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process.go rename to apps/daemon/internal/agent/clirunner/process.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go b/apps/daemon/internal/agent/clirunner/process_group_other.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_other.go rename to apps/daemon/internal/agent/clirunner/process_group_other.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go b/apps/daemon/internal/agent/clirunner/process_group_unix.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_unix.go rename to apps/daemon/internal/agent/clirunner/process_group_unix.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go b/apps/daemon/internal/agent/clirunner/process_group_unix_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go rename to apps/daemon/internal/agent/clirunner/process_group_unix_test.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/daemon/internal/agent/clirunner/process_group_windows.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go rename to apps/daemon/internal/agent/clirunner/process_group_windows.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/daemon/internal/agent/clirunner/process_group_windows_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go rename to apps/daemon/internal/agent/clirunner/process_group_windows_test.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go b/apps/daemon/internal/agent/clirunner/process_job_windows.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go rename to apps/daemon/internal/agent/clirunner/process_job_windows.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go b/apps/daemon/internal/agent/clirunner/process_observe_darwin_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go rename to apps/daemon/internal/agent/clirunner/process_observe_darwin_test.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go b/apps/daemon/internal/agent/clirunner/process_observe_linux_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go rename to apps/daemon/internal/agent/clirunner/process_observe_linux_test.go diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_test.go b/apps/daemon/internal/agent/clirunner/process_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/clirunner/process_test.go rename to apps/daemon/internal/agent/clirunner/process_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/approval_policy.go b/apps/daemon/internal/agent/codex/approval_policy.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/approval_policy.go rename to apps/daemon/internal/agent/codex/approval_policy.go diff --git a/apps/parsar-daemon/internal/agent/codex/approval_policy_test.go b/apps/daemon/internal/agent/codex/approval_policy_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/approval_policy_test.go rename to apps/daemon/internal/agent/codex/approval_policy_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go b/apps/daemon/internal/agent/codex/cancellation_outcome.go similarity index 95% rename from apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go rename to apps/daemon/internal/agent/codex/cancellation_outcome.go index 021de0ea5..61ed7ba6f 100644 --- a/apps/parsar-daemon/internal/agent/codex/cancellation_outcome.go +++ b/apps/daemon/internal/agent/codex/cancellation_outcome.go @@ -3,7 +3,7 @@ package codex import ( "sync" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type cancellationOutcomeState struct { diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go new file mode 100644 index 000000000..0ba51146f --- /dev/null +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -0,0 +1,27 @@ +package codex + +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.Steerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) + _ agent.PermissionResponder = (*Session)(nil) + _ agent.UserChoiceResponder = (*Session)(nil) + _ agent.WorkspaceReader = (*Session)(nil) + _ agent.WorkspaceDirectoryLister = (*Session)(nil) + _ agent.WorkspaceWriter = (*Session)(nil) + _ agent.Prepared = (*Prepared)(nil) + _ agent.PreparedCancellation = (*Prepared)(nil) + _ agent.WorkspaceReader = (*Executor)(nil) + _ agent.WorkspaceDirectoryLister = (*Executor)(nil) + _ agent.WorkspaceWriter = (*Executor)(nil) + _ agent.WorkspaceReader = (*Prepared)(nil) + _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) + _ agent.WorkspaceWriter = (*Prepared)(nil) +) diff --git a/apps/daemon/internal/agent/codex/environment.go b/apps/daemon/internal/agent/codex/environment.go new file mode 100644 index 000000000..51ba1f0f6 --- /dev/null +++ b/apps/daemon/internal/agent/codex/environment.go @@ -0,0 +1,62 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Check the native provider instead of assuming an older binary honors the flag. +func verifyNoExecutionEnvironment(ctx context.Context, rpc *JSONRPCClient) error { + for _, id := range []string{"local", "remote"} { + status, err := nativeEnvironmentStatus(ctx, rpc, id) + if err != nil { + return fmt.Errorf("codex: cannot confirm disabled execution environment: %w", err) + } + if status != "unknown" { + return fmt.Errorf("codex: execution environment %s was not disabled", id) + } + } + return nil +} + +func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) (string, error) { + raw, err := rpc.Request(ctx, "environment/status", map[string]string{"environmentId": id}) + if err != nil { + return "", err + } + var result struct { + Status string `json:"status"` + } + if json.Unmarshal(raw, &result) != nil || result.Status == "" { + return "", fmt.Errorf("codex: invalid native environment status") + } + return result.Status, nil +} + +// Native still recognizes the retired transport variables. Reject them before +// setup so inherited or operator options cannot select a separate executor. +// The explicit none selector remains part of native execution isolation. +func validateNativeTransportEnvironment(req proto.PromptRequestPayload) error { + options, err := buildSessionEnv(req.AgentOptions) + if err != nil { + return err + } + for _, environment := range [][]string{os.Environ(), options} { + for _, entry := range environment { + key, value, _ := strings.Cut(entry, "=") + if value == "" { + continue + } + if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { + return errors.New("codex: retired executor transport configuration is not supported") + } + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/daemon/internal/agent/codex/environment_local.go similarity index 85% rename from apps/parsar-daemon/internal/agent/codex/environment_local.go rename to apps/daemon/internal/agent/codex/environment_local.go index a42dc8c62..9fa3f3c2e 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/daemon/internal/agent/codex/environment_local.go @@ -4,7 +4,7 @@ import ( "os" "runtime" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" ) // SupportsLocalEnvironment checks deployment prerequisites, not public admission. diff --git a/apps/parsar-daemon/internal/agent/codex/environment_retired_test.go b/apps/daemon/internal/agent/codex/environment_retired_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/environment_retired_test.go rename to apps/daemon/internal/agent/codex/environment_retired_test.go index 98882260d..08dab1f6b 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_retired_test.go +++ b/apps/daemon/internal/agent/codex/environment_retired_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/environment_test.go b/apps/daemon/internal/agent/codex/environment_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/environment_test.go rename to apps/daemon/internal/agent/codex/environment_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/error_classification.go b/apps/daemon/internal/agent/codex/error_classification.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/error_classification.go rename to apps/daemon/internal/agent/codex/error_classification.go index 482811a97..14670ec16 100644 --- a/apps/parsar-daemon/internal/agent/codex/error_classification.go +++ b/apps/daemon/internal/agent/codex/error_classification.go @@ -3,7 +3,7 @@ package codex import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Only the exact root terminal TurnError is authoritative. Error notifications, diff --git a/apps/daemon/internal/agent/codex/error_classification_test.go b/apps/daemon/internal/agent/codex/error_classification_test.go new file mode 100644 index 000000000..cc6e21340 --- /dev/null +++ b/apps/daemon/internal/agent/codex/error_classification_test.go @@ -0,0 +1,95 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestPinnedTerminalErrorClassification(t *testing.T) { + for value, want := range map[string]string{ + "unauthorized": "authentication_error", "usageLimitExceeded": "usage_limit_exceeded", "rateLimitExceeded": "rate_limit_exceeded", + "contextWindowExceeded": "context_length_exceeded", "serverOverloaded": "server_overloaded", "internalServerError": "server_error", + "badRequest": "invalid_request", "cyberPolicy": "cyber_policy", "sessionBudgetExceeded": "", "misalignmentPolicyViolation": "", + "threadRollbackFailed": "", "sandboxError": "", "other": "", "unknownFuture": "", + } { + raw, _ := json.Marshal(value) + got := classifyTurnError(&TurnError{Message: "unauthorized 429 timeout", CodexErrorInfo: raw}) + if got.Code != want || got.HTTPStatus != nil { + t.Fatalf("%s: %+v", value, got) + } + } + for _, tc := range []struct { + raw, code string + status int + }{ + {`{"httpConnectionFailed":{"httpStatusCode":401}}`, "connection_failed", 401}, + {`{"responseStreamConnectionFailed":{"httpStatusCode":503}}`, "connection_failed", 503}, + {`{"responseStreamDisconnected":{"httpStatusCode":null}}`, "connection_failed", 0}, + {`{"responseTooManyFailedAttempts":{"httpStatusCode":429}}`, "rate_limit_exceeded", 0}, + {`{"responseTooManyFailedAttempts":{"httpStatusCode":502}}`, "connection_failed", 502}, + {`{"httpConnectionFailed":{"httpStatusCode":"401"}}`, "connection_failed", 0}, + {`{"httpConnectionFailed":{"httpStatusCode":65535}}`, "connection_failed", 0}, + {`{"httpConnectionFailed":null}`, "", 0}, {`{"activeTurnNotSteerable":{"turnKind":"review"}}`, "", 0}, + {`{"httpConnectionFailed":{},"other":{}}`, "", 0}, {`null`, "", 0}, + } { + got := classifyTurnError(&TurnError{CodexErrorInfo: json.RawMessage(tc.raw)}) + status := 0 + if got.HTTPStatus != nil { + status = *got.HTTPStatus + } + if got.Code != tc.code || status != tc.status { + t.Fatalf("%s: %+v", tc.raw, got) + } + } +} + +func TestClassificationOnlyFromRootTerminalError(t *testing.T) { + for _, mode := range []string{"failed", "recovered", "notification-only"} { + t.Run(mode, func(t *testing.T) { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{})} + s.registerHandlers() + s.setThreadID("root") + scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) + scopeNotification(t, s, "error", `{"threadId":"root","turnId":"turn","error":{"message":"retry","codexErrorInfo":"unauthorized"},"willRetry":true}`) + scopeNotification(t, s, "turn/completed", `{"threadId":"child","turn":{"id":"turn","status":"failed","error":{"message":"child","codexErrorInfo":"usageLimitExceeded"}}}`) + status, errJSON := "failed", `{"message":"terminal","codexErrorInfo":"serverOverloaded"}` + if mode == "recovered" { + status = "completed" + } + if mode == "notification-only" { + errJSON = "null" + } + scopeNotification(t, s, "turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"`+status+`","usage":{"inputTokens":7,"outputTokens":2},"error":`+errJSON+`}}`) + errors, done := 0, false + for env := range out { + if env.Type == proto.TypeError { + errors++ + var p proto.ErrorPayload + _ = env.DecodePayload(&p) + want := "" + if mode == "failed" { + want = "server_overloaded" + } + if p.Code != want { + t.Fatal(p) + } + } + if env.Type == proto.TypeDone { + done = true + var p proto.DonePayload + _ = env.DecodePayload(&p) + if p.Metadata[proto.DoneMetaAgentSessionID] != "root" || p.Usage.InputTokens != 7 { + t.Fatal(p) + } + } + } + if !done || errors != map[bool]int{true: 0, false: 1}[mode == "recovered"] { + t.Fatal("terminal lost", done, errors) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/execution_controls.go b/apps/daemon/internal/agent/codex/execution_controls.go similarity index 90% rename from apps/parsar-daemon/internal/agent/codex/execution_controls.go rename to apps/daemon/internal/agent/codex/execution_controls.go index bd7fb8c0e..1ffde7adf 100644 --- a/apps/parsar-daemon/internal/agent/codex/execution_controls.go +++ b/apps/daemon/internal/agent/codex/execution_controls.go @@ -3,7 +3,7 @@ package codex import ( "maps" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func executionOptions(req proto.PromptRequestPayload) map[string]any { diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go new file mode 100644 index 000000000..2ffed69d6 --- /dev/null +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -0,0 +1,57 @@ +package codex + +import ( + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestExecutionControlsOverrideWithoutMutatingNativeOptions(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + original := map[string]any{"model": "test-model", "web_search": "live", "model_verbosity": "high"} + request := proto.PromptRequestPayload{AgentOptions: original} + if got := executionOptions(request); !reflect.DeepEqual(got, original) { + t.Fatal("ordinary options changed") + } + for _, search := range []string{"disabled", "cached", "live"} { + for _, verbosity := range []string{"low", "medium", "high"} { + request.ExecutionControls = &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity} + options := executionOptions(request) + if options["model"] != "test-model" || original["web_search"] != "live" || original["model_verbosity"] != "high" { + t.Fatal("operator options mutated") + } + plan, err := BuildSessionPlan("run", "state", "", options) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"web_search": `"` + search + `"`, "model_verbosity": `"` + verbosity + `"`} + for _, kv := range plan.ExtraConfig { + if v, ok := want[kv[0]]; ok { + if kv[1] != v { + t.Fatal("native control differs", kv) + } + delete(want, kv[0]) + } + } + plan.Cleanup() + if len(want) != 0 { + t.Fatal("native settings omitted", want) + } + } + } +} + +func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + for _, controls := range []proto.ExecutionControls{ + {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, + {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, + } { + options := executionOptions(proto.PromptRequestPayload{ExecutionControls: &controls}) + if plan, err := BuildSessionPlan("run", "state", "", options); err == nil { + plan.Cleanup() + t.Fatal("invalid controls accepted", controls) + } + } +} diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go new file mode 100644 index 000000000..2f1e2eb09 --- /dev/null +++ b/apps/daemon/internal/agent/codex/executor.go @@ -0,0 +1,176 @@ +package codex + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Executor owns the prepared process, fixed plan and native thread. Each StartTurn +// creates independent receipt, observation, cancellation and output ownership. +type Executor struct { + mu sync.Mutex + prepared *Prepared + active *Session + closed bool + closeMu sync.Mutex +} + +func PrepareExecutor(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + return newExecutor(ctx, req, defaultSessionConfig()) +} + +func newExecutor(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Executor, error) { + p, err := newPreparation(ctx, req, cfg) + if err != nil { + if p != nil { + return &Executor{prepared: p}, err + } + return nil, err + } + p.mu.Lock() + p.started = true + close(p.transferred) + p.mu.Unlock() + return &Executor{prepared: p}, nil +} + +func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if out == nil || strings.TrimSpace(runID) == "" || input.Validate() != nil { + return nil, errors.New("codex: start requires a run identity, input and output") + } + if err := ctx.Err(); err != nil { + return nil, err + } + e.mu.Lock() + base := e.prepared.session + if e.closed || base.cancelCtx.Err() != nil || !base.rpc.Alive() { + e.mu.Unlock() + return nil, errors.New("codex: executor unavailable") + } + previous := e.active + if previous != nil { + select { + case <-previous.waitDone: + default: + e.mu.Unlock() + return nil, errors.New("codex: previous turn has not settled") + } + if previous.settlementErr != nil || !previous.settlement.Reusable { + e.mu.Unlock() + return nil, errors.New("codex: executor requires retirement") + } + } + functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} + turnCtx, cancel := context.WithCancel(base.cancelCtx) + s := &Session{executor: e, nativeHome: base.nativeHome, + functions: functions, observeMessages: base.observeMessages, + observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, + cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, + waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, + bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, interactions: newPendingCodexInteractions(), runID: runID, out: out} + if previous != nil { + s.threadID = previous.currentThreadID() + s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) + for id := range previous.retiredTurns { + s.retiredTurns[id] = true + } + previous.steering.mu.Lock() + s.retiredTurns[previous.steering.id] = true + previous.steering.mu.Unlock() + s.resolvedModel = previous.resolvedModel + previous.usageMu.Lock() + s.usageTotal = previous.usageTotal + previous.usageMu.Unlock() + } + e.active = s + if s.observeSubagentIdentities { + s.startSubagentObservations() + } + s.registerHandlers() + e.mu.Unlock() + req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, StrictResume: e.prepared.strictResume, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} + // Ownership precedes any native submission. Even an uncertain start returns the + // exact Turn so its caller can await settlement without replaying the input. + err := s.startNative(ctx, e.prepared.plan, req) + if err != nil { + s.emitTerminal("codex: native start failed", true) + s.finishAfterTerminal() + } + go s.settleExecutorTurn(err) + return s, err +} + +func (e *Executor) Close(ctx context.Context) error { + e.mu.Lock() + e.closed = true + e.mu.Unlock() + done := make(chan error, 1) + go func() { + e.closeMu.Lock() + defer e.closeMu.Unlock() + base := e.prepared.session + e.mu.Lock() + running := e.active + e.mu.Unlock() + if running != nil { + select { + case <-running.waitDone: + default: + // Try native cancellation before closing its transport, but never + // let an absent terminal receipt prevent resource retirement. + cancelCtx, stop := context.WithTimeout(ctx, 10*time.Second) + _ = running.Cancel(cancelCtx) + stop() + } + } + if running != nil && base.rpc.Alive() { + // A caller deadline only stops waiting. Cleanup retains a bounded + // opportunity to release native terminals before closing their owner. + cleanupCtx, stop := context.WithTimeout(context.Background(), rpcKillTimeout) + err := running.cleanupNativeTerminals(cleanupCtx) + stop() + if err != nil { + // Preserve the native owner and exact handles for a later Close. + done <- err + return + } + } + base.cancelFn() + err := base.rpc.Close() + e.mu.Lock() + active := e.active + e.mu.Unlock() + if err == nil && active != nil { + // A Turn's permanent outcome error survives Close. Cleanup succeeds + // when its work and output have stopped, not when that outcome changes. + select { + case <-active.waitDone: + case <-ctx.Done(): + err = ctx.Err() + } + } + if err == nil { + err = base.rpc.awaitReaders(ctx) + } + if err == nil { + e.prepared.plan.Cleanup() + } + done <- err + }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +var _ agent.Executor = (*Executor)(nil) + +func NewExecutorFactory() agent.ExecutorFactory { return PrepareExecutor } diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go new file mode 100644 index 000000000..49e96bc91 --- /dev/null +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -0,0 +1,164 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +// This opt-in test uses the real pinned harness and an explicitly configured +// Responses provider. Credentials are read from a private file, never arguments. +func TestExecutorNativeReuse(t *testing.T) { + 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, "") + } + cfg := defaultSessionConfig() + cfg.codexBinary = binary + cfg.logger = obslog.Discard() + req := proto.PromptRequestPayload{ + AgentKind: "codex", AgentStateKey: "executor-native", WorkDir: filepath.Join(isolated, "workspace"), + StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + AgentOptions: map[string]any{"model": model, "model_provider": map[string]any{"base_url": endpoint, "protocol": "responses", "api_key": strings.TrimSpace(string(key))}}, + FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, + } + 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/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go new file mode 100644 index 000000000..01245f944 --- /dev/null +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -0,0 +1,258 @@ +package codex + +import ( + "context" + "errors" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func executorFixture(t *testing.T, mode string) (*Executor, string) { + t.Helper() + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + t.Cleanup(cancelOwner) + e, err := newExecutor(ownerCtx, req, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Error(err) + } + }) + return e, root +} +func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) agent.TurnSettlement { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err != nil { + t.Fatal(err) + } + count := 0 + for frame := range out { + if frame.Type == proto.TypeDone { + count++ + } + } + if count != 1 { + t.Fatalf("terminal count %d", count) + } + return settlement +} +func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { + e, root := executorFixture(t, "complete") + var previous agent.Turn + for _, id := range []string{"one", "two"} { + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("healthy turn was not reusable") + } + if previous != nil { + if err := previous.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + } + previous = turn + } + counts := map[string]int{} + pids := map[int]bool{} + for _, f := range preparationFrames(t, root) { + counts[f.Method]++ + pids[f.PID] = true + } + if counts["initialize"] != 1 || counts["thread/start"] != 1 || counts["turn/start"] != 2 || counts["turn/interrupt"] != 0 || len(pids) != 1 { + t.Fatal(counts, pids) + } + if !e.prepared.session.rpc.Alive() { + t.Fatal("normal completion closed executor") + } +} +func TestExecutorCancellationSettlesThenReuses(t *testing.T) { + e, root := executorFixture(t, "complete") + out := make(chan proto.Envelope, 20) + first, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + if _, err := e.StartTurn(t.Context(), "overlap", proto.TextInput("answer"), make(chan proto.Envelope, 20)); err == nil { + t.Fatal("overlapping turn admitted") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := first.Cancel(ctx); err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, first, out).Reusable { + t.Fatal("cancelled healthy executor unavailable") + } + secondOut := make(chan proto.Envelope, 20) + second, err := e.StartTurn(t.Context(), "next", proto.TextInput("hold"), secondOut) + if err != nil { + t.Fatal(err) + } + if err := first.Cancel(ctx); err != nil { + t.Fatal(err) + } + // A stale handle cannot interrupt its successor. + interrupts := 0 + for _, f := range preparationFrames(t, root) { + if f.Method == "turn/interrupt" { + interrupts++ + } + } + if interrupts != 1 { + t.Fatalf("stale cancellation sent %d interrupts", interrupts) + } + if err := second.Cancel(ctx); err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, second, secondOut).Reusable { + t.Fatal("second cancellation did not settle") + } +} +func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { + for _, mode := range []string{"start-error", "disconnect"} { + t.Run(mode, func(t *testing.T) { + e, _ := executorFixture(t, mode) + out := make(chan proto.Envelope, 20) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + turn, err := e.StartTurn(ctx, "before", proto.TextInput("answer"), out) + if turn != nil || !errors.Is(err, context.Canceled) { + t.Fatal(turn, err) + } + select { + case <-out: + t.Fatal("pre-admission output was retained or closed") + default: + } + turn, err = e.StartTurn(t.Context(), "after", proto.TextInput("answer"), out) + if turn == nil || err == nil { + t.Fatal("uncertain submission lost owner", err) + } + settlement, settleErr := turn.AwaitSettlement(t.Context()) + if settleErr == nil || settlement.Reusable { + t.Fatal("failed submission fabricated native settlement", settlement, settleErr) + } + terminalCount := 0 + for frame := range out { + if frame.Type == proto.TypeDone { + terminalCount++ + } + } + if terminalCount != 1 { + t.Fatalf("failed submission terminal count = %d", terminalCount) + } + }) + } +} + +func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) + if err != nil { + t.Fatal(err) + } + rpc := NewJSONRPCClient(JSONRPCConfig{}) + rpc.process, rpc.cmd, rpc.stdin, rpc.alive = process, process.Cmd, process.Stdin, true + + var reap sync.Once + t.Cleanup(func() { process.Cancel(); reap.Do(rpc.waitChild) }) + _, cancel := context.WithCancel(t.Context()) + var cleaned atomic.Bool + e := &Executor{prepared: &Prepared{session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cleanup: func() { cleaned.Store(true) }}}} + ctx, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer stop() + if err = e.Close(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("unreaped close", err) + } + if cleaned.Load() { + t.Fatal("plan released before cleanup settled") + } + reap.Do(rpc.waitChild) + if err = e.Close(t.Context()); err != nil { + t.Fatal(err) + } + if !cleaned.Load() { + t.Fatal("cleanup retry did not release plan") + } +} + +func TestExecutorCloseSeparatesTurnFailureFromResourceCleanup(t *testing.T) { + e, root := executorFixture(t, "interrupt-error") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "cancel-failure", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := turn.Cancel(ctx); err == nil { + t.Fatal("native interrupt failure was hidden") + } + if _, err := turn.AwaitSettlement(ctx); err == nil { + t.Fatal("failed Turn unexpectedly settled successfully") + } + if err := e.Close(ctx); err != nil { + t.Fatal("failed Turn prevented confirmed resource cleanup", err) + } + if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { + t.Fatal("Close did not release native process and plan") + } + if _, err := turn.AwaitSettlement(ctx); err == nil { + t.Fatal("resource cleanup fabricated successful Turn settlement") + } +} + +func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { + e, root := executorFixture(t, "interrupt-no-terminal") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "missing-terminal", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + cancelCtx, stopCancel := context.WithTimeout(t.Context(), 5*time.Second) + defer stopCancel() + cancelled := make(chan error, 1) + go func() { cancelled <- turn.Cancel(cancelCtx) }() + waitPreparationMethod(t, root, "turn/interrupt") + closeCtx, stopClose := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer stopClose() + if err := e.Close(closeCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("missing receipt did not reach the cleanup deadline", err) + } + // Retrying waits on the same owner; the expired observation must not skip + // the transport close that can actually stop an unresponsive native Turn. + retry, stopRetry := context.WithTimeout(t.Context(), 2*time.Second) + defer stopRetry() + if err := e.Close(retry); err != nil { + t.Fatal("Close never retired the native process", err) + } + if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { + t.Fatal("unresponsive native process or plan still owned after Close") + } + select { + case cancelErr := <-cancelled: + if cancelErr == nil { + t.Fatal("resource cleanup fabricated native cancellation confirmation") + } + case <-retry.Done(): + t.Fatal("original cancellation waiter was abandoned") + } +} diff --git a/apps/daemon/internal/agent/codex/executor_turn.go b/apps/daemon/internal/agent/codex/executor_turn.go new file mode 100644 index 000000000..d02eb988c --- /dev/null +++ b/apps/daemon/internal/agent/codex/executor_turn.go @@ -0,0 +1,175 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-s.waitDone: + return s.settlement, s.settlementErr + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func (s *Session) settleExecutorTurn(startErr error) { + defer close(s.waitDone) + if startErr == nil { + select { + case <-s.outputDone: + case <-s.rpc.Done(): + s.emitTerminal("codex: connection closed before settlement", true) + case <-s.cancelCtx.Done(): + s.emitTerminal("codex: execution owner closed", true) + } + } + // Detach the old callbacks before waiting for their captured Turn and native + // replies. Later frames cannot acquire this Turn or redirect to a successor. + if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { + s.cancelFn() + s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) + } + if !s.nativeSettled.Load() { + s.settlementErr = errors.Join(s.settlementErr, errors.New("codex: native Turn settlement is unconfirmed")) + } + s.rpc.detachHandlers() + s.operationMu.Lock() + s.operationsClosed = true + s.operationMu.Unlock() + s.stopSteering() + s.stopFunctionCalls() + s.stopCodexInteractionTimers() + if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { + s.cancelFn() + s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) + s.settlement = agent.TurnSettlement{Reason: "native_execution_unavailable"} + } else { + s.settlement = agent.TurnSettlement{Reusable: true} + } + s.closeRunOutput() + s.operations.Wait() + if s.subagents != nil { + s.subagents.mu.Lock() + err := s.subagents.cancelResult + s.subagents.mu.Unlock() + if err != nil { + s.settlement = agent.TurnSettlement{Reason: "child_settlement_unconfirmed"} + s.settlementErr = errors.Join(s.settlementErr, err, s.rpc.Close()) + } + } + // The terminal callback has returned before detachHandlers completes. A + // concurrent cancellation owns only this Turn and finishes before reuse. + s.operationMu.Lock() + ready := s.cancelReady + s.operationMu.Unlock() + if ready != nil { + <-ready + if s.cancelErr != nil { + s.settlement.Reusable = false + s.settlement.Reason = "cancellation_unconfirmed" + s.settlementErr = errors.Join(s.settlementErr, s.cancelErr, s.rpc.Close()) + } + } + if s.cancelled.Load() && s.nativeSettled.Load() && s.cancelErr == nil && s.rpc.Alive() { + cleanupCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) + err := s.cleanupNativeTerminals(cleanupCtx) + stop() + if err != nil { + s.settlement = agent.TurnSettlement{Reason: "native_terminal_cleanup_unconfirmed"} + s.settlementErr = errors.Join(s.settlementErr, err) + } + } + s.cancelFn() +} + +func (s *Session) beginOperation() bool { + if s.executor == nil { + return true + } + s.operationMu.Lock() + defer s.operationMu.Unlock() + if s.operationsClosed { + return false + } + s.operations.Add(1) + return true +} +func (s *Session) endOperation() { + if s.executor != nil { + s.operations.Done() + } +} + +func (s *Session) cancelExecutorTurn(ctx context.Context) error { + s.operationMu.Lock() + if s.operationsClosed { + s.operationMu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err + } + s.cancelOnce.Do(func() { + s.cancelled.Store(true) + s.cancelReady = make(chan struct{}) + go func() { + defer close(s.cancelReady) + s.stopFunctionCalls() + turnID, active := s.stopSteering() + s.stopCodexInteractionTimers() + if !s.terminal.Load() && active { + if turnID == "" { + s.cancelErr = errors.New("codex: cancellation has no native turn identity") + s.cancelFn() + _ = s.rpc.Close() + return + } + interruptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, err := s.rpc.request(interruptCtx, "turn/interrupt", TurnInterruptParams{ThreadID: s.currentThreadID(), TurnID: turnID}, func(frame any) error { return s.rpc.writeFrameContext(interruptCtx, frame) }) + cancel() + if err != nil { + s.cancelErr = err + s.cancelFn() + _ = s.rpc.Close() + return + } + } + if s.subagents != nil { + s.cancelErr = s.cancelSubagentWork(s.cancelCtx) + } + }() + }) + s.operationMu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err +} + +var _ agent.Turn = (*Session)(nil) + +// Server callbacks retain their originating Turn. MCP elicitation may be a +// standalone thread-scoped request in the native protocol; a supplied Turn ID +// must still match exactly. +func (s *Session) onServerRequest(method string, handler ServerRequestHandler) { + s.rpc.OnServerRequest(method, func(raw json.RawMessage, id any) (any, error) { + if s.executor != nil { + var scope struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + } + if json.Unmarshal(raw, &scope) != nil || !s.isRootThread(scope.ThreadID) || s.terminal.Load() || + (scope.TurnID != "" && !s.isRootTurn(scope.ThreadID, scope.TurnID)) || + (scope.TurnID == "" && method != "mcpServer/elicitation/request") { + return nil, errors.New("codex: request does not belong to the active turn") + } + } + if !s.beginOperation() { + return nil, errors.New("codex: turn has settled") + } + defer s.endOperation() + return handler(raw, id) + }) +} diff --git a/apps/parsar-daemon/internal/agent/codex/export_test.go b/apps/daemon/internal/agent/codex/export_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/export_test.go rename to apps/daemon/internal/agent/codex/export_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/function_receipt_test.go b/apps/daemon/internal/agent/codex/function_receipt_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/function_receipt_test.go rename to apps/daemon/internal/agent/codex/function_receipt_test.go index cc1bc12d0..decbf235e 100644 --- a/apps/parsar-daemon/internal/agent/codex/function_receipt_test.go +++ b/apps/daemon/internal/agent/codex/function_receipt_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestFunctionResultWaitsForNativeCompletion(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/function_receipts.go b/apps/daemon/internal/agent/codex/function_receipts.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/function_receipts.go rename to apps/daemon/internal/agent/codex/function_receipts.go index 8c594209d..ba6727b16 100644 --- a/apps/parsar-daemon/internal/agent/codex/function_receipts.go +++ b/apps/daemon/internal/agent/codex/function_receipts.go @@ -8,8 +8,8 @@ import ( "reflect" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type functionReply struct { diff --git a/apps/parsar-daemon/internal/agent/codex/function_write_receipt_test.go b/apps/daemon/internal/agent/codex/function_write_receipt_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/function_write_receipt_test.go rename to apps/daemon/internal/agent/codex/function_write_receipt_test.go index cbb23e47a..49cdc6826 100644 --- a/apps/parsar-daemon/internal/agent/codex/function_write_receipt_test.go +++ b/apps/daemon/internal/agent/codex/function_write_receipt_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type confirmedResultWriter struct { diff --git a/apps/daemon/internal/agent/codex/functions.go b/apps/daemon/internal/agent/codex/functions.go new file mode 100644 index 000000000..e2a359d4b --- /dev/null +++ b/apps/daemon/internal/agent/codex/functions.go @@ -0,0 +1,114 @@ +package codex + +import ( + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type dynamicFunctionTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +type functionCalls struct { + mu sync.Mutex + definitions []dynamicFunctionTool + names map[string]bool + pending map[string]*pendingFunction + closed bool +} + +func prepareFunctionTools(tools []proto.FunctionTool) (*functionCalls, error) { + state := &functionCalls{names: map[string]bool{}, pending: map[string]*pendingFunction{}} + if len(tools) > 64 { + return nil, errors.New("at most 64 function tools are supported") + } + for _, tool := range tools { + var schema map[string]any + if strings.TrimSpace(tool.Name) == "" || state.names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, errors.New("function tools require unique names and object schemas") + } + state.names[tool.Name] = true + state.definitions = append(state.definitions, dynamicFunctionTool{Type: "function", Name: tool.Name, Description: tool.Description, InputSchema: append(json.RawMessage(nil), tool.Parameters...)}) + } + return state, nil +} + +func (s *Session) handleFunctionCall(raw json.RawMessage, rpcID any) (any, error) { + var call struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + CallID string `json:"callId"` + Namespace *string `json:"namespace"` + Tool string `json:"tool"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(raw, &call); err != nil { + return nil, err + } + if s.functions == nil || !s.functions.names[call.Tool] || call.Namespace != nil || call.CallID == "" || !s.isRootTurn(call.ThreadID, call.TurnID) || !json.Valid(call.Arguments) { + return nil, errors.New("unexpected function call") + } + s.functions.mu.Lock() + if s.functions.closed || s.cancelled.Load() || s.terminal.Load() || s.functions.pending[call.CallID] != nil || len(s.functions.pending) >= 64 { + s.functions.mu.Unlock() + return nil, errors.New("function call cannot be admitted") + } + s.functions.pending[call.CallID] = &pendingFunction{rpcID: rpcID, turnID: call.TurnID, name: call.Tool, receipt: make(chan error, 1)} + s.functions.mu.Unlock() + env, err := proto.NewEnvelope(proto.TypeFunctionCall, s.runID, proto.FunctionCallPayload{CallID: call.CallID, Name: call.Tool, Arguments: call.Arguments}) + if err == nil { + err = s.sendFunctionCall(env) + } + if err != nil { + s.functions.mu.Lock() + delete(s.functions.pending, call.CallID) + s.functions.mu.Unlock() + return nil, err + } + return DeferReply, nil +} + +func (s *Session) sendFunctionCall(env proto.Envelope) error { + s.outMu.RLock() + defer s.outMu.RUnlock() + if s.outClosed { + return agent.ErrUnknownFunctionCall + } + timer := time.NewTimer(terminalSendTimeout) + defer timer.Stop() + select { + case s.out <- env: + return nil + case <-s.cancelCtx.Done(): + return s.cancelCtx.Err() + case <-timer.C: + return errors.New("function call delivery timed out") + } +} + +type functionContent struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + ImageURL *string `json:"imageUrl,omitempty"` +} + +func (s *Session) stopFunctionCalls() { + if s.functions != nil { + s.functions.mu.Lock() + s.functions.closed = true + for _, pending := range s.functions.pending { + pending.receipt <- agent.ErrUnknownFunctionCall + } + clear(s.functions.pending) + s.functions.mu.Unlock() + } +} diff --git a/apps/daemon/internal/agent/codex/functions_test.go b/apps/daemon/internal/agent/codex/functions_test.go new file mode 100644 index 000000000..1fc34979f --- /dev/null +++ b/apps/daemon/internal/agent/codex/functions_test.go @@ -0,0 +1,124 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestFunctionCallWaitsAndRepliesOnce(t *testing.T) { + for _, success := range []bool{true, false} { + t.Run(map[bool]string{true: "success", false: "failure"}[success], func(t *testing.T) { + tc, srv, cleanup := NewTestClient() + defer cleanup() + s, out := newInteractionTestSession(tc.JSONRPCClient) + var err error + s.functions, err = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) + if err != nil { + t.Fatal(err) + } + s.setThreadID("thread") + s.startSteering("thread", "turn") + params := map[string]any{"threadId": "thread", "turnId": "turn", "callId": "call", "tool": "lookup", "arguments": map[string]string{"ticket": "42"}} + if err := SendServerRequest(srv, "rpc-call", "item/tool/call", params); err != nil { + t.Fatal(err) + } + select { + case env := <-out: + var call proto.FunctionCallPayload + if err := env.DecodePayload(&call); err != nil || env.Type != proto.TypeFunctionCall || env.ID != "run-test" || call.CallID != "call" || call.Name != "lookup" { + t.Fatal(env, err) + } + case <-time.After(time.Second): + t.Fatal("missing function call") + } + text, image, empty := "answer", "https://example.com/result.png", "" + content := []proto.InputContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &image}, {Type: "input_text", Text: &empty}} + if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Content: []proto.InputContent{{Type: "input_audio"}}}); err == nil { + t.Fatal("invalid result consumed the pending call") + } + finished := make(chan error, 1) + go func() { + finished <- s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Success: success, Content: content}) + }() + var reply struct { + ID string `json:"id"` + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(srv.FromClient).Decode(&reply); err != nil { + t.Fatal(err) + } + if reply.ID != "rpc-call" { + t.Fatal(reply.ID) + } + status := "completed" + if !success { + status = "failed" + } + var observed map[string]any + if err := json.Unmarshal(reply.Result, &observed); err != nil { + t.Fatal(err) + } + observed["type"], observed["id"], observed["tool"], observed["status"] = "dynamicToolCall", "call", "lookup", status + native, _ := json.Marshal(map[string]any{"threadId": "thread", "turnId": "turn", "item": observed}) + s.onItemCompleted(native) + if err := <-finished; err != nil { + t.Fatal(err) + } + var result struct { + Success bool `json:"success"` + Content []functionContent `json:"contentItems"` + } + if err := json.Unmarshal(reply.Result, &result); err != nil || result.Success != success || !reflect.DeepEqual(result.Content, []functionContent{{Type: "inputText", Text: &text}, {Type: "inputImage", ImageURL: &image}, {Type: "inputText", Text: &empty}}) { + t.Fatal(string(reply.Result), err) + } + if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Content: content}); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatal(err) + } + }) + } +} + +func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { + tc, _, cleanup := NewTestClient() + defer cleanup() + s, _ := newInteractionTestSession(tc.JSONRPCClient) + s.setThreadID("thread") + s.startSteering("thread", "turn") + s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) + for _, params := range []string{ + `{"threadId":"other","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`, + `{"threadId":"thread","turnId":"turn","callId":"a","tool":"unknown","arguments":{}}`, + `{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","namespace":"foreign","arguments":{}}`, + } { + if _, err := s.handleFunctionCall(json.RawMessage(params), "rpc"); err == nil { + t.Fatal("unexpected call accepted", params) + } + } + s.stopFunctionCalls() + if _, err := s.handleFunctionCall(json.RawMessage(`{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`), "rpc"); err == nil { + t.Fatal("closed run accepted call") + } + if err := s.SubmitFunctionResult(context.Background(), proto.FunctionResultPayload{CallID: "a", Content: []proto.InputContent{}}); !errors.Is(err, agent.ErrUnknownFunctionCall) { + t.Fatal(err) + } +} + +func TestFunctionToolDefinitionsRejectAmbiguousInput(t *testing.T) { + for _, tools := range [][]proto.FunctionTool{ + {{Name: "", Parameters: json.RawMessage(`{}`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`[]`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`null`)}}, + {{Name: "lookup", Parameters: json.RawMessage(`{}`)}, {Name: "lookup", Parameters: json.RawMessage(`{}`)}}, + } { + if _, err := prepareFunctionTools(tools); err == nil { + t.Fatal("invalid function accepted", tools) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/generation_config.go b/apps/daemon/internal/agent/codex/generation_config.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/generation_config.go rename to apps/daemon/internal/agent/codex/generation_config.go diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go new file mode 100644 index 000000000..43f4d9cb2 --- /dev/null +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -0,0 +1,92 @@ +package codex + +import ( + "context" + "encoding/json" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "slices" + "testing" +) + +func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + plan, err := BuildSessionPlan("run", "native-config", "", map[string]any{ + "model": "fixture", "harness_config": map[string]any{"model_reasoning_effort": "high"}, + "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, + }) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"high"`}) { + t.Fatalf("native configuration not applied: %+v", plan.ExtraConfig) + } +} + +func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { + _, err := BuildSessionPlan("run", "", "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}) + if err != harnessconfig.ErrHarnessConfig { + t.Fatalf("configuration must fail before filesystem preparation: %v", err) + } +} + +func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { + for _, resumeID := range []string{"", "fixture-native-thread"} { + t.Run("resume="+resumeID, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") + req.AgentSessionID = resumeID + native := map[string]any{"model_reasoning_effort": "high"} + req.AgentOptions["harness_config"] = native + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + t.Cleanup(cancelOwner) + e, err := newExecutor(ownerCtx, req, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Error(err) + } + }) + native["model_reasoning_effort"] = "low" + for _, id := range []string{"one", "two"} { + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("healthy Turn was not reusable") + } + } + counts := map[string]int{} + for _, frame := range preparationFrames(t, root) { + counts[frame.Method]++ + if frame.Method != "turn/start" { + continue + } + var wire struct { + CollaborationMode struct { + Settings map[string]any `json:"settings"` + } `json:"collaborationMode"` + } + if json.Unmarshal(frame.Params, &wire) != nil || wire.CollaborationMode.Settings["reasoning_effort"] != "high" { + t.Fatal("Turn lost the frozen native reasoning effort", string(frame.Params)) + } + } + method := "thread/start" + if resumeID != "" { + method = "thread/resume" + } + if counts[method] != 1 || counts["turn/start"] != 2 { + t.Fatal("expected one native thread and two Turns", counts) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go b/apps/daemon/internal/agent/codex/hosted_skills.go similarity index 88% rename from apps/parsar-daemon/internal/agent/codex/hosted_skills.go rename to apps/daemon/internal/agent/codex/hosted_skills.go index 9ad5ee8e0..7b20452cb 100644 --- a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go +++ b/apps/daemon/internal/agent/codex/hosted_skills.go @@ -6,8 +6,8 @@ import ( "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" ) func verifyHostedSkills(skills []agentcapabilities.InstalledSkill) error { diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go b/apps/daemon/internal/agent/codex/hosted_skills_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go rename to apps/daemon/internal/agent/codex/hosted_skills_test.go diff --git a/apps/daemon/internal/agent/codex/installation.go b/apps/daemon/internal/agent/codex/installation.go new file mode 100644 index 000000000..fe8e645b5 --- /dev/null +++ b/apps/daemon/internal/agent/codex/installation.go @@ -0,0 +1,31 @@ +package codex + +import ( + "context" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + binary := func(dir string) string { + name := "codex" + if runtime.GOOS == "windows" { + name += ".exe" + } + 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" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_CODEX_BIN": 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" { + return fmt.Errorf("Codex installation is incompatible") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/mcp_config.go rename to apps/daemon/internal/agent/codex/mcp_config.go diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config_test.go b/apps/daemon/internal/agent/codex/mcp_config_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/mcp_config_test.go rename to apps/daemon/internal/agent/codex/mcp_config_test.go diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go new file mode 100644 index 000000000..0013b3a76 --- /dev/null +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -0,0 +1,68 @@ +package codex + +import ( + "encoding/json" + "os" + "slices" + "strings" + "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{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, + }} + servers, env, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}) + if err != nil || len(servers) != 2 || len(env) != 2 { + t.Fatal("environment declarations were not projected", err) + } + executable, _ := os.Executable() + if servers["local"].Command != executable || !servers["local"].ApproveTools || + !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { + t.Fatal("native stdio bypasses the packaged launcher") + } + remote := servers["remote"] + if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || + !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || + !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { + t.Fatal("private header values changed") + } + fixture := map[string]any{"config": map[string]any{ + "mcp_oauth_credentials_store": "file", "features": map[string]bool{"plugins": false, "apps": false}, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, + "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve", "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, + }, + }} + raw, _ := json.Marshal(fixture) + if !matchesMCPConfig(raw, servers) { + t.Fatal("qualified native projection rejected") + } + corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) + if matchesMCPConfig(json.RawMessage(corrupt), servers) { + t.Fatal("different native launcher accepted") + } +} + +func TestEnvironmentMCPRejectsUnqualifiedNetworkAndCredentialChanges(t *testing.T) { + for _, access := range []string{"", "restricted", "disabled"} { + local := &proto.LocalEnvironment{NetworkAccess: access, MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "local", Type: "stdio"}}}} + if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { + t.Errorf("unqualified MCP network accepted: %s", access) + } + } + token := "user-token" + local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} + if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { + t.Fatal("plaintext bearer accepted") + } + local.MCP[0].Server.URL = "https://example.com/mcp" + if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp"}}}); err == nil { + t.Fatal("service and environment identity collision accepted") + } +} diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go new file mode 100644 index 000000000..badc57960 --- /dev/null +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -0,0 +1,83 @@ +package codex + +import ( + "crypto/rand" + "errors" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// One projection consumes both public and installed Runtime bindings. A non-nil +// empty public declaration still owns the complete native MCP configuration. +func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConfig, []string, error) { + bindings, err := agent.ResolveMCPBindings(req) + if err != nil { + return nil, nil, err + } + if bindings == nil { + return nil, nil, nil + } + servers := make(map[string]mcpServerConfig, len(bindings)) + var env []string + for _, binding := range bindings { + if binding.ServerLabel == "codex_apps" { + return nil, nil, errors.New("codex: reserved MCP server label") + } + server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} + if binding.Stdio != nil { + server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) + } + if binding.BearerToken != nil { + server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() + env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) + } + if len(binding.HTTPHeaders) > 0 { + server.EnvHTTPHeaders = map[string]string{} + for name, value := range binding.HTTPHeaders { + reference := "OAC_RUNTIME_MCP_HEADER_" + rand.Text() + server.EnvHTTPHeaders[name] = reference + env = append(env, reference+"="+value) + } + } + servers[server.Name] = server + } + return servers, env, nil +} + +func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { + var codexHome string + for _, entry := range plan.Env { + if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { + codexHome = value + } + } + if codexHome == "" || !filepath.IsAbs(codexHome) { + return errors.New("codex: public MCP requires a private native home") + } + // Native OAuth defaults to the global keyring. File mode confines lookup to + // this owned history directory; never delete existing credentials to admit it. + if _, err := os.Lstat(filepath.Join(codexHome, ".credentials.json")); !errors.Is(err, os.ErrNotExist) { + return errors.New("codex: public MCP requires a native home without stored MCP credentials") + } + if err := writeCodexMCPConfig(codexHome, servers); err != nil { + return errors.New("codex: cannot write public MCP configuration") + } + if plan.Cwd == "" { + plan.Cwd = codexHome + } + for _, feature := range []string{"plugins", "apps"} { + plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) + if !slices.Contains(plan.DisableFeatures, feature) { + plan.DisableFeatures = append(plan.DisableFeatures, feature) + } + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) + plan.mcpServers = servers + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go rename to apps/daemon/internal/agent/codex/mcp_http_bearer_test.go index ee3f3583c..4c1ed5d8c 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go rename to apps/daemon/internal/agent/codex/mcp_http_preflight.go diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go rename to apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index 98afda442..aa3c87918 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go new file mode 100644 index 000000000..f8ad4fea8 --- /dev/null +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -0,0 +1,161 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + tools := []string{"lookup.docs", `quote"tool`} + denyAll := []string{} + servers := []proto.MCPHTTPServer{ + {ConnectionOrigin: "service", ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, + {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, + } + original := map[string]any{ + "mcp_servers": map[string]any{"operator": map[string]any{"command": "operator-mcp"}}, + "enable_features": []any{"apps", "plugins", "unrelated"}, + } + req := proto.PromptRequestPayload{AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: original} + req.AgentOptions = executionOptions(req) + plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if original["mcp_servers"] == nil || req.AgentOptions["mcp_servers"] != nil { + t.Fatal("declaration failed to replace operator MCP without mutation") + } + if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) || !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { + t.Fatal("native profile was not pinned") + } + home, err := allocCodexHome(req.AgentStateKey) + if err != nil { + t.Fatal(err) + } + if plan.Cwd != home { + t.Fatal("empty cwd did not resolve to the private home") + } + config, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{`[mcp_servers."docs.server"]`, `enabled_tools = ["lookup.docs", "quote\"tool"]`, `enabled_tools = []`, "required = true"} { + if !strings.Contains(string(config), expected) { + t.Fatalf("missing native config %q", expected) + } + } + if strings.Contains(string(config), "operator") { + t.Fatal("operator MCP was rendered") + } + tools[0] = "mutated" + if (*plan.mcpServers["docs.server"].EnabledTools)[0] != "lookup.docs" { + t.Fatal("prepared allowlist retained caller-owned memory") + } + history := filepath.Join(home, "retained-history.jsonl") + if err := os.WriteFile(history, []byte("native-history"), 0o600); err != nil { + t.Fatal(err) + } + servers = []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} + second, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer second.Cleanup() + config, err = os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil || strings.Contains(string(config), "docs.server") || !strings.Contains(string(config), "replacement") || strings.Contains(string(config), "enabled_tools") || strings.Contains(string(config), "required") { + t.Fatal("cold configuration retained old servers or changed unrestricted tools", err) + } + retained, err := os.ReadFile(history) + if err != nil || string(retained) != "native-history" { + t.Fatal("configuration reset changed native history", err) + } +} + +func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { + valid := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} + for _, req := range []proto.PromptRequestPayload{ + {MCPHTTPServers: &valid}, + } { + if _, _, err := runtimeMCPServers(req); err == nil { + t.Fatal("non-service profile accepted") + } + } + for _, server := range []proto.MCPHTTPServer{ + {ConnectionOrigin: "service", ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, + } { + servers := []proto.MCPHTTPServer{server} + if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatal("unsupported configuration was accepted or exposed", err) + } + } + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + home, err := allocCodexHome("credentials") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".credentials.json") + stored := []byte(`{"synthetic":"private"}`) + if err := os.WriteFile(path, stored, 0o600); err != nil { + t.Fatal(err) + } + req := proto.PromptRequestPayload{AgentStateKey: "credentials", DisableExecutionEnvironment: true, MCPHTTPServers: &valid} + if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { + t.Fatal("existing MCP credentials accepted") + } + if after, err := os.ReadFile(path); err != nil || !reflect.DeepEqual(after, stored) { + t.Fatal("existing credentials were modified", err) + } +} + +// This is the pinned native serializer's config/read shape, not live discovery. +func mcpHTTPConfigResponse(servers map[string]mcpServerConfig) map[string]any { + entries := make(map[string]any, len(servers)) + for name, server := range servers { + entry := map[string]any{"url": server.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "required": server.Required} + if server.EnabledTools != nil { + entry["enabled_tools"] = append([]string{}, (*server.EnabledTools)...) + } + if server.BearerTokenEnvVar != "" { + entry["bearer_token_env_var"] = server.BearerTokenEnvVar + } + entries[name] = entry + } + return map[string]any{"config": map[string]any{"mcp_servers": entries, "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}} +} + +func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { + t.Helper() + data, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestPublicMCPBearerRequiresHTTPS(t *testing.T) { + req := proto.PromptRequestPayload{DisableExecutionEnvironment: true} + token := "synthetic-private-token" + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} + req.MCPHTTPServers = &servers + if _, _, err := runtimeMCPServers(req); err == nil || strings.Contains(err.Error(), token) { + t.Fatal("plaintext bearer accepted or exposed") + } + servers[0].ServerURL = "https://tools.example/mcp" + if _, _, err := runtimeMCPServers(req); err != nil { + t.Fatal("HTTPS bearer declaration rejected", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/mcp_required_test.go rename to apps/daemon/internal/agent/codex/mcp_required_test.go index 7def29158..bd4bbec2b 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // The controlled native response checks adapter ordering. Real MCP initialization diff --git a/apps/daemon/internal/agent/codex/message_input.go b/apps/daemon/internal/agent/codex/message_input.go new file mode 100644 index 000000000..57e0ccf7c --- /dev/null +++ b/apps/daemon/internal/agent/codex/message_input.go @@ -0,0 +1,25 @@ +package codex + +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + +// nativeInput keeps content order. Codex accepts a flat input list, so message +// boundaries use the same blank-line separator as the existing text path. +func nativeInput(messages proto.MessageInput) ([]UserInput, error) { + if err := messages.Validate(); err != nil { + return nil, err + } + var input []UserInput + for i, message := range messages { + if i > 0 { + input = append(input, UserInput{Type: UserInputText, Text: "\n\n"}) + } + for _, part := range message.Content { + if part.Type == "input_text" { + input = append(input, UserInput{Type: UserInputText, Text: *part.Text}) + } else { + input = append(input, UserInput{Type: UserInputRemoteImg, URL: *part.ImageURL}) + } + } + } + return input, nil +} diff --git a/apps/daemon/internal/agent/codex/message_input_test.go b/apps/daemon/internal/agent/codex/message_input_test.go new file mode 100644 index 000000000..5efa14a46 --- /dev/null +++ b/apps/daemon/internal/agent/codex/message_input_test.go @@ -0,0 +1,20 @@ +package codex + +import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "testing" +) + +func TestNativeInputRetainsImageOrderAndMessageSeparator(t *testing.T) { + image := "data:image/png;base64,aW1hZ2U=" + messages := proto.TextInput(" before ") + messages[0].Content = append(messages[0].Content, proto.InputContent{Type: "input_image", ImageURL: &image}, proto.TextInput(" after ")[0].Content[0]) + messages = append(messages, proto.TextInput("next")...) + input, err := nativeInput(messages) + if err != nil { + t.Fatal(err) + } + if len(input) != 5 || input[0].Text != " before " || input[1].Type != UserInputRemoteImg || input[1].URL != image || input[2].Text != " after " || input[3].Text != "\n\n" || input[4].Text != "next" { + t.Fatalf("native input changed: %+v", input) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go b/apps/daemon/internal/agent/codex/model_catalog_command_other.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/model_catalog_command_other.go rename to apps/daemon/internal/agent/codex/model_catalog_command_other.go diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix.go rename to apps/daemon/internal/agent/codex/model_catalog_command_unix.go diff --git a/apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/model_catalog_command_unix_test.go rename to apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/model_route_test.go b/apps/daemon/internal/agent/codex/model_route_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/model_route_test.go rename to apps/daemon/internal/agent/codex/model_route_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/model_verbosity.go rename to apps/daemon/internal/agent/codex/model_verbosity.go diff --git a/apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go b/apps/daemon/internal/agent/codex/model_verbosity_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go rename to apps/daemon/internal/agent/codex/model_verbosity_test.go index fc2f3e03a..be995dc8a 100644 --- a/apps/parsar-daemon/internal/agent/codex/model_verbosity_test.go +++ b/apps/daemon/internal/agent/codex/model_verbosity_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCatalogVerbositySupport(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/native_hooks.go b/apps/daemon/internal/agent/codex/native_hooks.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/native_hooks.go rename to apps/daemon/internal/agent/codex/native_hooks.go diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go new file mode 100644 index 000000000..b6504a647 --- /dev/null +++ b/apps/daemon/internal/agent/codex/options.go @@ -0,0 +1,450 @@ +package codex + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" +) + +// SessionPlan holds the resolved per-prompt launch plan derived from +// the daemon's PromptRequestPayload. +type SessionPlan struct { + // Cwd is the validated working directory passed to codex (and to + // the spawned app-server). Empty when the caller provided no work_dir. + Cwd string + + // Env is the full environment slice (KEY=value) to layer onto + // os.Environ() before spawning. Includes CODEX_HOME, plus any + // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. + Env []string + + // ExtraConfig is a list of `-c key=value` overrides applied at the + // app-server CLI. Used to layer model_reasoning_summary etc. without + // editing config.toml. + ExtraConfig [][2]string + + // EnableFeatures / DisableFeatures forward to `--enable / --disable` + // flags. Today empty by default; reserved for future ARC opt-in. + EnableFeatures []string + DisableFeatures []string + + // Non-nil for declared service or Environment MCP, including private references. + mcpServers map[string]mcpServerConfig + + // Model is the slug to request on thread/start. Empty inherits the + // codex.config.toml default. + Model string + + // ModelProvider is the slug pinned on thread/start so codex routes + // the prompt through the [model_providers.] entry we wrote + // into /config.toml. Empty leaves codex on its builtin + // "openai" provider (only valid when the caller really wants + // public api.openai.com + OPENAI_API_KEY env), so the normal path is + // oacProviderSlug. + ModelProvider string + + // SystemPrompt is forwarded as developerInstructions on thread/start. + SystemPrompt string + + // CollaborationMode selects Codex's default or plan tool surface. + CollaborationMode CollaborationModeKind + + // ModelReasoningEffort is frozen for launch and every native Turn. + ModelReasoningEffort string + + // ApprovalPolicy + Sandbox apply to both new and resumed threads. + ApprovalPolicy AskForApproval + Sandbox SandboxMode + Permissions string + + // Cleanup is the deferred housekeeping the session must run after the child exits. + Cleanup func() +} + +// BuildSessionPlan derives a SessionPlan from PromptRequestPayload's +// fields. The work_dir / opts shape mirrors how claudecode + opencode +// consume their own opts: a string-keyed map of any. +// +// The agent_options keys this function reads: +// +// model string codex model slug, e.g. "gpt-5.5" +// system_prompt string forwarded as developerInstructions +// override_system_prompt string replaces system_prompt entirely when +// non-empty (mirrors claudecode/opencode) +// env map[string]any extra env vars (KEY=string-value) +// mcp_servers map[string]any rendered MCP server config — written +// to /config.toml [mcp_servers] +// model_provider object frozen upstream protocol, endpoint, credential and token limits +// reasoning_summary string one of auto/concise/detailed/none — +// routed via -c model_reasoning_summary +// mode string Codex collaboration mode: default/plan +// enable_features []any string list, forwarded as --enable +// disable_features []any string list, forwarded as --disable +// +// The codex binary itself is resolved via PATH only — there's no +// per-prompt override knob. If a deployment needs a custom binary +// location, set it via the daemon's process environment (PATH / +// codexBinary in sessionConfig) rather than per-call. +// +// Daemon-managed Codex sessions bypass approvals and the engine sandbox. +func BuildSessionPlan(runID, agentStateKey, workDir string, opts map[string]any) (SessionPlan, error) { + cleanup := func() {} + plan := SessionPlan{ + CollaborationMode: CollaborationModeDefault, + ApprovalPolicy: AskForApproval{String: "never"}, + Sandbox: SandboxDangerFullAcces, + Cleanup: cleanup, + } + + nativeConfig, err := harnessconfiguration.Configuration().PrepareHarnessConfig(opts) + if err != nil { + return plan, err + } + + if value, present := opts["model_provider"]; present && (value == nil || stringOpt(opts, "model") == "") { + return plan, errors.New("codex: model and complete model_provider are required") + } + + if value, present := opts["web_search"]; present { + switch value { + case "disabled", "cached", "live": + default: + return plan, fmt.Errorf("codex: web_search must be disabled, cached or live") + } + } + + if value, present := opts["model_verbosity"]; present { + switch value { + case "low", "medium", "high": + default: + return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") + } + } + + resolvedCwd, err := resolveWorkDirCodex(workDir) + if err != nil { + return plan, err + } + plan.Cwd = resolvedCwd + + plan.Model = stringOpt(opts, "model") + plan.SystemPrompt = stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + plan.SystemPrompt = override + } + if mode := CollaborationModeKind(stringOpt(opts, "mode")); mode != "" { + switch mode { + case CollaborationModeDefault, CollaborationModePlan: + plan.CollaborationMode = mode + default: + return plan, fmt.Errorf("codex: unsupported collaboration mode %q", mode) + } + } + + env, err := buildSessionEnv(opts) + if err != nil { + return plan, err + } + + codexHome, err := allocCodexHome(agentStateKey) + if err != nil { + return plan, err + } + if err := resetGeneratedConfig(codexHome); err != nil { + return plan, err + } + env = append(env, "CODEX_HOME="+codexHome) + + // MCP servers come pre-rendered from server/internal/connector/agentdaemon + // (capabilityAdditions.MCPServers, rendered via render.TargetCodex) + // as a map of name → {command,args,env}. Write them into + // /config.toml so codex picks them up on startup. + mcpServers, err := normaliseMCPServers(opts["mcp_servers"]) + if err != nil { + return plan, err + } + if len(mcpServers) > 0 { + if err := writeCodexMCPConfig(codexHome, mcpServers); err != nil { + return plan, err + } + } + + provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) + if err != nil { + cleanup() + return plan, err + } + if hasProvider { + if err := writeCodexProviderConfig(codexHome, provider); err != nil { + cleanup() + return plan, err + } + plan.ModelProvider = oacProviderSlug + } + + plan.Env = env + plan.Cleanup = cleanup + plan.ExtraConfig = extraConfigFromOpts(opts) + if effort, ok := nativeConfig["model_reasoning_effort"].(string); ok { + plan.ModelReasoningEffort = effort + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_reasoning_effort", strconv(effort)}) + } + if plan.ModelProvider != "" { + // Pin model_provider at the CLI layer so codex skips its builtin + // "openai" provider — without this the [model_providers.oac] + // block we wrote into config.toml would be loaded but never + // selected (the default model_provider is "openai"). + plan.ExtraConfig = append(plan.ExtraConfig, + [2]string{"model_provider", strconv(plan.ModelProvider)}) + } + plan.EnableFeatures = stringListOpt(opts, "enable_features") + plan.DisableFeatures = stringListOpt(opts, "disable_features") + return plan, nil +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func resolveWorkDirCodex(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("codex: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("codex: work_dir must be absolute or start with ~/, got %q", trimmed) + } + // Match claudecode's resolveSessionWorkDir: mkdir -p so a user + // naming a fresh project root in the agent wizard works on first + // run instead of erroring with "does not exist". + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("codex: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func allocCodexHome(agentStateKey string) (string, error) { + if strings.TrimSpace(agentStateKey) == "" { + return "", fmt.Errorf("codex: agentStateKey required for CODEX_HOME allocation") + } + root, err := paths.Root() + if err != nil { + return "", err + } + parts := strings.Split(agentStateKey, "/") + safeParts := make([]string, 0, len(parts)) + for _, part := range parts { + if safe := safePathPartCodex(part); safe != "" { + safeParts = append(safeParts, safe) + } + } + if len(safeParts) == 0 { + return "", fmt.Errorf("codex: invalid agentStateKey %q", agentStateKey) + } + dirParts := append([]string{root, "daemon", "agent-sessions"}, safeParts...) + dir := filepath.Join(dirParts...) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("codex: create CODEX_HOME %s: %w", dir, err) + } + return dir, nil +} + +func resetGeneratedConfig(codexHome string) error { + path := filepath.Join(codexHome, "config.toml") + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("codex: remove generated config %s: %w", path, err) + } + return nil +} + +func buildSessionEnv(opts map[string]any) ([]string, error) { + env := []string{ + "DISABLE_TELEMETRY=1", + } + raw, ok := opts["env"] + if !ok || raw == nil { + return env, nil + } + envMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex.BuildSessionPlan: env must be object, got %T", raw) + } + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return nil, fmt.Errorf("codex.BuildSessionPlan: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func stringListOpt(opts map[string]any, key string) []string { + if opts == nil { + return nil + } + raw, ok := opts[key] + if !ok || raw == nil { + return nil + } + arr, ok := raw.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + +func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { + if raw == nil { + return nil, nil + } + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex: mcp_servers must be object, got %T", raw) + } + out := make(map[string]mcpServerConfig, len(m)) + for name, v := range m { + entry, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) + } + srv := mcpServerConfig{Name: name} + if url, ok := entry["url"].(string); ok { + srv.URL = strings.TrimSpace(url) + } + if cmd, ok := entry["command"].(string); ok { + srv.Command = cmd + } + if args, ok := entry["args"].([]any); ok { + for _, a := range args { + if s, ok := a.(string); ok { + srv.Args = append(srv.Args, s) + } + } + } + if env, ok := entry["env"].(map[string]any); ok { + srv.Env = make(map[string]string, len(env)) + for k, val := range env { + if s, ok := val.(string); ok { + srv.Env[k] = s + } + } + } + if headers, ok := entry["headers"].(map[string]any); ok { + srv.Headers = make(map[string]string, len(headers)) + for key, value := range headers { + if text, ok := value.(string); ok { + srv.Headers[key] = text + } + } + } else if headers, ok := entry["headers"].(map[string]string); ok { + srv.Headers = headers + } + if srv.Command == "" && srv.URL == "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) + } + if srv.Command != "" && srv.URL != "" { + return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) + } + out[name] = srv + } + return out, nil +} + +// normaliseProviderConfig validates and renders the frozen native provider. +func normaliseProviderConfig(raw any) (providerConfig, bool, error) { + if raw == nil { + return providerConfig{}, false, nil + } + provider, err := harnessconfiguration.Configuration().ParseProvider(raw) + if err != nil { + return providerConfig{}, false, err + } + return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"}, true, nil +} + +// intOpt extracts an integer-shaped value from a map. JSON-decoded +// numbers arrive as float64; tests sometimes pass int directly. Both +// are accepted; non-numeric / missing yields 0. +func intOpt(m map[string]any, key string) int { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + } + return 0 +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +func safePathPartCodex(runID string) string { + var b strings.Builder + for _, r := range runID { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "" { + return "run" + } + return out +} + +// strconv quotes a value as a TOML string. Done by reusing the JSON +// encoder for escape rules — TOML strings accept the same standard +// escape set so this is wire-safe. +func strconv(s string) string { + q, _ := json.Marshal(s) + return string(q) +} diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go new file mode 100644 index 000000000..068ef091b --- /dev/null +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -0,0 +1,292 @@ +package codex + +import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + if plan.ApprovalPolicy.String != "never" { + t.Fatalf("default policy must bypass approvals, got %+v", plan.ApprovalPolicy) + } + if plan.Sandbox != SandboxDangerFullAcces { + t.Fatalf("default sandbox = %s, want danger-full-access", plan.Sandbox) + } + if plan.Cleanup == nil { + t.Fatal("Cleanup must be non-nil") + } + plan.Cleanup() +} + +func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "env": map[string]any{ + "OPENAI_API_KEY": "sk-test", + }, + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + hasCodexHome := false + hasOpenAI := false + hasTelemetry := false + for _, kv := range plan.Env { + switch { + case strings.HasPrefix(kv, "CODEX_HOME="): + hasCodexHome = true + case kv == "OPENAI_API_KEY=sk-test": + hasOpenAI = true + case kv == "DISABLE_TELEMETRY=1": + hasTelemetry = true + } + } + if !hasCodexHome { + t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) + } + if !hasOpenAI { + t.Fatalf("env missing OPENAI_API_KEY: %+v", plan.Env) + } + if !hasTelemetry { + t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) + } +} + +func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { + stateKey := "conv-stable/agent-stable/codex" + planA, err := BuildSessionPlan("run-a", stateKey, "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan A: %v", err) + } + planB, err := BuildSessionPlan("run-b", stateKey, "", nil) + if err != nil { + t.Fatalf("BuildSessionPlan B: %v", err) + } + if codexHomeFromEnv(planA.Env) == "" || codexHomeFromEnv(planA.Env) != codexHomeFromEnv(planB.Env) { + t.Fatalf("CODEX_HOME must be stable by state key: A=%q B=%q", codexHomeFromEnv(planA.Env), codexHomeFromEnv(planB.Env)) + } +} + +func TestBuildSessionPlan_RoutesReasoningSummary(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "reasoning_summary": "detailed", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if len(plan.ExtraConfig) != 1 { + t.Fatalf("ExtraConfig = %+v", plan.ExtraConfig) + } + kv := plan.ExtraConfig[0] + if kv[0] != "model_reasoning_summary" { + t.Fatalf("override key = %q", kv[0]) + } + if kv[1] != `"detailed"` { + t.Fatalf("override value = %q (must be TOML-quoted)", kv[1]) + } +} + +func codexHomeFromEnv(env []string) string { + for _, kv := range env { + if strings.HasPrefix(kv, "CODEX_HOME=") { + return strings.TrimPrefix(kv, "CODEX_HOME=") + } + } + return "" +} + +func TestBuildSessionPlan_OverrideSystemPromptReplacesAppend(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "system_prompt": "user base", + "override_system_prompt": "you are pirate", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.SystemPrompt != "you are pirate" { + t.Fatalf("SystemPrompt = %q, want %q", plan.SystemPrompt, "you are pirate") + } +} + +func TestBuildSessionPlan_EmptyOverrideKeepsSystemPrompt(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "system_prompt": "user base", + "override_system_prompt": "", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.SystemPrompt != "user base" { + t.Fatalf("SystemPrompt = %q, want %q (empty override should not clobber)", plan.SystemPrompt, "user base") + } +} + +func TestBuildSessionPlan_ParsesCollaborationMode(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mode": "plan", + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.CollaborationMode != CollaborationModePlan { + t.Fatalf("CollaborationMode = %q, want plan", plan.CollaborationMode) + } +} + +func TestBuildSessionPlan_OmittedModeRetainsCurrentInstructions(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + for _, mode := range []string{"", "default"} { + opts := map[string]any{"system_prompt": "current reference", "model": "MiniMax-M3"} + if mode != "" { + opts["mode"] = mode + } + plan, err := BuildSessionPlan("run", "conv/agent/codex", "", opts) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { + t.Fatalf("mode %q did not retain the default turn instructions: %+v", mode, plan) + } + } +} + +func TestBuildSessionPlan_RejectsUnknownCollaborationMode(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mode": "autopilot", + }) + if err == nil || !strings.Contains(err.Error(), "unsupported collaboration mode") { + t.Fatalf("expected unsupported collaboration mode error, got %v", err) + } +} + +func TestBuildSessionPlan_RejectsRelativeWorkDir(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "relative/dir", nil) + if err == nil { + t.Fatal("relative work_dir must error") + } +} + +// TestBuildSessionPlan_CreatesMissingWorkDir: align with claudecode — +// a non-existent absolute path is mkdir -p'd so a user can pin a fresh +// project root in the agent wizard. Without this, codex agents would +// hard-fail the first turn instead of running. +func TestBuildSessionPlan_CreatesMissingWorkDir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", target, nil) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + if plan.Cwd != target { + t.Fatalf("plan.Cwd = %q, want %q", plan.Cwd, target) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildSessionPlan_WritesMCPConfig(t *testing.T) { + plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mcp_servers": map[string]any{ + "docs": map[string]any{ + "command": "docs-server", + "args": []any{"--port", "8080"}, + "env": map[string]any{"TOKEN": "abc"}, + }, + }, + }) + if err != nil { + t.Fatalf("BuildSessionPlan: %v", err) + } + defer plan.Cleanup() + // Find CODEX_HOME so we can verify the config.toml was written there. + codexHome := "" + for _, kv := range plan.Env { + if strings.HasPrefix(kv, "CODEX_HOME=") { + codexHome = strings.TrimPrefix(kv, "CODEX_HOME=") + break + } + } + if codexHome == "" { + t.Fatal("CODEX_HOME not in plan.Env") + } + // mcp_config.go writes /config.toml — verify it exists + // and contains the rendered server. + bodyBytes, err := os.ReadFile(codexHome + "/config.toml") + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + body := string(bodyBytes) + if !strings.Contains(body, `[mcp_servers."docs"]`) { + t.Fatalf("config.toml missing docs server: %s", body) + } + if !strings.Contains(body, `command = "docs-server"`) { + t.Fatalf("config.toml missing command: %s", body) + } +} + +func TestBuildSessionPlan_MissingMCPCommandErrors(t *testing.T) { + _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ + "mcp_servers": map[string]any{ + "broken": map[string]any{ + "args": []any{"--x"}, + // no command + }, + }, + }) + if err == nil { + t.Fatal("missing mcp command must surface as error") + } +} + +func TestNativeInputPreservesText(t *testing.T) { + inputs, err := nativeInput(proto.TextInput(" hello world ")) + if err != nil { + t.Fatal(err) + } + if len(inputs) != 1 { + t.Fatalf("len = %d", len(inputs)) + } + if inputs[0].Type != UserInputText || inputs[0].Text != " hello world " { + t.Fatalf("input = %+v", inputs[0]) + } +} + +func TestFirstUserInput_EmptyReturnsNil(t *testing.T) { + if got, err := nativeInput(proto.TextInput("")); err == nil { + t.Fatalf("empty prompt must return nil, got %+v", got) + } +} + +// The shared validator admits whitespace-only text; Codex receives it unchanged. +func TestFirstUserInput_WhitespaceIsUnchanged(t *testing.T) { + inputs, err := nativeInput(append(proto.TextInput(" "), proto.TextInput("\n\t")...)) + if err != nil || len(inputs) != 3 || inputs[0].Text != " " || inputs[1].Text != "\n\n" || inputs[2].Text != "\n\t" { + t.Fatalf("input = %+v, %v", inputs, err) + } +} + +func TestStringListOpt_FiltersEmpties(t *testing.T) { + got := stringListOpt(map[string]any{ + "enable_features": []any{"a", "", " ", "b"}, + }, "enable_features") + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("filter = %+v", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/daemon/internal/agent/codex/permission_profile.go similarity index 88% rename from apps/parsar-daemon/internal/agent/codex/permission_profile.go rename to apps/daemon/internal/agent/codex/permission_profile.go index a5d7a76d9..46911ca67 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/daemon/internal/agent/codex/permission_profile.go @@ -2,7 +2,7 @@ package codex import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Runtime execution uses the current user; isolation belongs to its outer host. diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/daemon/internal/agent/codex/permission_profile_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/permission_profile_test.go rename to apps/daemon/internal/agent/codex/permission_profile_test.go index e8ec49b35..58a08c3d7 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -1,7 +1,7 @@ package codex import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "os" "path/filepath" "strings" diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go new file mode 100644 index 000000000..44d750c04 --- /dev/null +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -0,0 +1,156 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +// Prepare connects the native harness without creating a thread or starting model +// work. req supplies configuration and a stable state key, but no RunID or Prompt. +// owner owns the entire harness lifetime, including the eventual Session; it must +// not be a disposable readiness-request context. Initialization/readiness use their +// existing operation-local deadlines. Close an unused preparation explicitly. +func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { + return newPreparation(owner, req, defaultSessionConfig()) +} + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("codex: nil out channel") + } + runID, prompt := req.RunID, req.Input + req.AgentStateKey = effectiveAgentStateKey(req) + req.RunID, req.Input = "", nil + prepared, err := newPreparation(parent, req, cfg) + if err != nil { + return nil, err + } + defer prepared.Close() + return prepared.start(parent, runID, prompt, out) +} + +func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Prepared, error) { + if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { + return nil, errors.New("codex: structured output is not qualified") + } + if req.WorkspaceReadOnly { + return nil, errors.New("codex: workspace reads use the local Runtime interface") + } + if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { + return nil, errors.New("codex: native-session recovery requires strict private state") + } + if req.RunID != "" || len(req.Input) != 0 { + return nil, errors.New("codex: preparation does not accept a run identity or prompt") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.codexBinary == "" { + cfg.codexBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = rpcKillTimeout + } + functions, err := prepareFunctionTools(req.FunctionTools) + if err != nil { + return nil, err + } + req.AgentStateKey = effectiveAgentStateKey(req) + + req.AgentOptions = executionOptions(req) + plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) + if err != nil { + return nil, err + } + + cancelCtx, cancelFn := context.WithCancel(parent) + + rpcCfg := JSONRPCConfig{ + Binary: cfg.codexBinary, + EnableFeatures: plan.EnableFeatures, + DisableFeatures: plan.DisableFeatures, + Cwd: plan.Cwd, + Env: append(os.Environ(), plan.Env...), + LogTag: "codex-preparation", + Logger: cfg.logger, + } + for _, kv := range plan.ExtraConfig { + rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) + } + + rpc := NewJSONRPCClient(rpcCfg) + + s := &Session{ + nativeHome: nativeHomeFromPlan(plan), + functions: functions, + observeMessages: req.ObserveMessages, + + observeToolObservations: req.ObserveToolObservations, + observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, + cfg: cfg, + rpc: rpc, + cancelCtx: cancelCtx, + cancelFn: cancelFn, + waitDone: make(chan struct{}), + cleanup: sync.OnceFunc(plan.Cleanup), + bufs: NewItemBuffers(), + resolvedModel: plan.Model, + interactions: newPendingCodexInteractions(), + } + plan.Cleanup = s.cleanup + p := &Prepared{ + session: s, plan: plan, + resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, + transferred: make(chan struct{}), + } + + initParams := InitializeParams{ + ClientInfo: InitializeClientInfo{Name: "oac-daemon", Version: "0.0.0"}, + Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, + } + if _, err := rpc.Start(cancelCtx, initParams); err != nil { + return p.preparationFailed(fmt.Errorf("codex: rpc start: %w", err)) + } + if req.ExecutionControls != nil && req.ExecutionControls.DisableProgrammaticToolCalling { + if err := verifyProgrammaticToolsDisabled(cancelCtx, rpc); err != nil { + return p.preparationFailed(err) + } + } + if req.DisableExecutionEnvironment { + if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { + return p.preparationFailed(err) + } + } + if s.observeSubagentIdentities { + if err := verifySubagentObservationProfile(cancelCtx, rpc, plan.Cwd); err != nil { + return p.preparationFailed(err) + } + } + + if plan.mcpServers != nil { + if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { + return p.preparationFailed(err) + } + } + if len(skillRoots) > 0 { + if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { + return p.preparationFailed(fmt.Errorf("codex: register skill root: %w", err)) + } + } + + go p.watchOwner() + return p, nil +} + +func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { + if err := p.Close(); err != nil { + return p, errors.Join(cause, err) + } + return nil, cause +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_close_test.go b/apps/daemon/internal/agent/codex/preparation_close_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/preparation_close_test.go rename to apps/daemon/internal/agent/codex/preparation_close_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go rename to apps/daemon/internal/agent/codex/preparation_helpers_test.go index 2957a4718..b07f5c04e 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type preparationFrame struct { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go similarity index 91% rename from apps/parsar-daemon/internal/agent/codex/preparation_router_test.go rename to apps/daemon/internal/agent/codex/preparation_router_test.go index e653de49f..a55759523 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -1,22 +1,22 @@ package codex -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/google/uuid" "os" "path/filepath" "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type preparationWireSender chan proto.Envelope diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/prepared.go rename to apps/daemon/internal/agent/codex/prepared.go index b4e3a0b78..454b71976 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -6,8 +6,8 @@ import ( "strings" "sync" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Prepared owns a connected native resource until Start transfers it to a Session. diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go b/apps/daemon/internal/agent/codex/prepared_cancel_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go rename to apps/daemon/internal/agent/codex/prepared_cancel_test.go index f336d84a9..3cc919e44 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go +++ b/apps/daemon/internal/agent/codex/prepared_cancel_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_test.go b/apps/daemon/internal/agent/codex/prepared_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/prepared_test.go rename to apps/daemon/internal/agent/codex/prepared_test.go index a92863590..8d73a17c9 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared_test.go +++ b/apps/daemon/internal/agent/codex/prepared_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/programmatic_tools.go b/apps/daemon/internal/agent/codex/programmatic_tools.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/programmatic_tools.go rename to apps/daemon/internal/agent/codex/programmatic_tools.go index 4ed615b6c..e0d377fa0 100644 --- a/apps/parsar-daemon/internal/agent/codex/programmatic_tools.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools.go @@ -6,7 +6,7 @@ import ( "errors" "slices" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) var programmaticFeatures = []string{"code_mode", "code_mode_only", "code_mode_prewarm"} diff --git a/apps/parsar-daemon/internal/agent/codex/programmatic_tools_test.go b/apps/daemon/internal/agent/codex/programmatic_tools_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/programmatic_tools_test.go rename to apps/daemon/internal/agent/codex/programmatic_tools_test.go index 8281ac9d6..df9064dc4 100644 --- a/apps/parsar-daemon/internal/agent/codex/programmatic_tools_test.go +++ b/apps/daemon/internal/agent/codex/programmatic_tools_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestProgrammaticToolsExplicitDisableOverridesNativeOptions(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/protocol.go rename to apps/daemon/internal/agent/codex/protocol.go diff --git a/apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go b/apps/daemon/internal/agent/codex/protocol_wire_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/protocol_wire_test.go rename to apps/daemon/internal/agent/codex/protocol_wire_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/provider_config.go rename to apps/daemon/internal/agent/codex/provider_config.go diff --git a/apps/parsar-daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/provider_config_test.go rename to apps/daemon/internal/agent/codex/provider_config_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/recovery.go b/apps/daemon/internal/agent/codex/recovery.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/recovery.go rename to apps/daemon/internal/agent/codex/recovery.go diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go new file mode 100644 index 000000000..b08156784 --- /dev/null +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -0,0 +1,197 @@ +package codex + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +func TestNativeSessionRecoveryRequiresPinnedNative(t *testing.T) { + for _, version := range []string{"", "codex-cli 0.153.3", "codex-cli 0.153.4", "codex-cli 0.154.0"} { + if SupportsNativeSessionRecovery(version) != (version == "codex-cli 0.153.4") { + t.Fatalf("unexpected recovery capability for %q", version) + } + } +} + +func TestRequiredHistoryResolution(t *testing.T) { + for _, scenario := range []string{"recover", "fresh", "known", "missing", "ambiguous", "paged", "omitted-cursor", "missing-parent", "child", "fork", "ephemeral", "wrong-cwd", "wrong-home", "archived", "lookup-error", "resume-error", "malformed"} { + t.Run(scenario, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + home := t.TempDir() + plan := SessionPlan{Cwd: "/workspace", Env: []string{"CODEX_HOME=" + home}} + row := map[string]any{"id": "original", "parentThreadId": nil, "forkedFromId": nil, "ephemeral": false, "source": "vscode", "cwd": plan.Cwd, "path": filepath.Join(home, "sessions", "rollout.jsonl")} + switch scenario { + case "missing-parent": + delete(row, "parentThreadId") + case "child": + row["parentThreadId"] = "parent" + case "fork": + row["forkedFromId"] = "old" + case "ephemeral": + row["ephemeral"] = true + case "wrong-cwd": + row["cwd"] = "/another" + case "wrong-home": + row["path"] = "/another/sessions/rollout.jsonl" + } + req := proto.PromptRequestPayload{StrictResume: true, RequireExistingNativeSession: true} + if scenario == "fresh" { + req.RequireExistingNativeSession = false + } + if scenario == "known" { + req.AgentSessionID = "original" + } + session := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "recovery-test")}} + methods := make(chan []string, 1) + go func() { + decoder, encoder := json.NewDecoder(server.FromClient), json.NewEncoder(server.ToClient) + var seen []string + defer func() { methods <- seen }() + for { + var frame struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if decoder.Decode(&frame) != nil { + return + } + seen = append(seen, frame.Method) + response := map[string]any{"id": frame.ID} + switch frame.Method { + case "thread/list": + if frame.Params["limit"] != float64(2) || frame.Params["useStateDbOnly"] != false { + return + } + data := []any{row} + if frame.Params["archived"] == true || scenario == "missing" { + data = []any{} + } + if scenario == "archived" && frame.Params["archived"] == true { + data = []any{row} + } + if scenario == "ambiguous" { + data = append(data, row) + } + page := map[string]any{"data": data, "nextCursor": nil} + if scenario == "paged" { + page["nextCursor"] = "next" + } + if scenario == "omitted-cursor" { + delete(page, "nextCursor") + } + response["result"] = page + if scenario == "malformed" { + response["result"] = "invalid" + } + if scenario == "lookup-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -1, "message": "failed"} + } + case "thread/resume": + if frame.Params["threadId"] != "original" { + return + } + response["result"] = map[string]any{"thread": map[string]string{"id": "original"}} + if scenario == "resume-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -1, "message": "failed"} + } + case "thread/start": + response["result"] = map[string]any{"thread": map[string]string{"id": "fresh"}} + default: + return + } + if encoder.Encode(response) != nil { + return + } + } + }() + err := session.resolveThread(req, plan) + wantSuccess := scenario == "recover" || scenario == "fresh" || scenario == "known" + if (err == nil) != wantSuccess { + t.Fatalf("resolution error=%v", err) + } + cleanup() + seen := <-methods + for _, method := range seen { + if method == "thread/start" && scenario != "fresh" { + t.Fatal("silently created fresh history", seen) + } + if method == "thread/list" && scenario == "known" { + t.Fatal("searched instead of using authoritative ID", seen) + } + } + if scenario == "recover" && (len(seen) != 3 || seen[2] != "thread/resume" || session.currentThreadID() != "original") { + t.Fatal("did not resume exact root", seen) + } + }) + } +} + +func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.RequireExistingNativeSession = true + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + assertPreparationOnly(t, root) + out := make(chan proto.Envelope, 16) + session, err := p.Start(t.Context(), "recovery-run", proto.TextInput("continue"), out) + if err != nil { + t.Fatal(err) + } + defer session.Cancel(context.Background()) + select { + case <-p.session.waitDone: + case <-time.After(4 * time.Second): + t.Fatal("recovery did not terminate") + } + found := false + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/list" { + found = true + } + if frame.Method == "thread/start" || frame.Method == "turn/start" { + t.Fatal("missing history started work", frame.Method) + } + } + if !found { + t.Fatal("prepared start lost recovery requirement") + } +} + +func TestRecoveryRequiresStrictPrivateExecution(t *testing.T) { + for _, mode := range []string{"non-strict", "no-state", "read-only"} { + t.Run(mode, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.RequireExistingNativeSession = true + switch mode { + case "non-strict": + req.StrictResume = false + case "no-state": + req.AgentStateKey = "" + case "read-only": + req.WorkspaceReadOnly = true + } + if p, err := newPreparation(t.Context(), req, cfg); err == nil { + p.Close() + t.Fatal("invalid recovery admitted") + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("invalid recovery launched native process") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/resume.go b/apps/daemon/internal/agent/codex/resume.go similarity index 93% rename from apps/parsar-daemon/internal/agent/codex/resume.go rename to apps/daemon/internal/agent/codex/resume.go index 0161f3a12..6ee852b1c 100644 --- a/apps/parsar-daemon/internal/agent/codex/resume.go +++ b/apps/daemon/internal/agent/codex/resume.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) resolveThread(req proto.PromptRequestPayload, plan SessionPlan) error { diff --git a/apps/parsar-daemon/internal/agent/codex/resume_test.go b/apps/daemon/internal/agent/codex/resume_test.go similarity index 95% rename from apps/parsar-daemon/internal/agent/codex/resume_test.go rename to apps/daemon/internal/agent/codex/resume_test.go index 5c95d854f..1da859d76 100644 --- a/apps/parsar-daemon/internal/agent/codex/resume_test.go +++ b/apps/daemon/internal/agent/codex/resume_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) func TestStrictResumeDoesNotStartFresh(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/rpc.go rename to apps/daemon/internal/agent/codex/rpc.go index 19a232309..665877de3 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -14,9 +14,9 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // rpcDefaultRequestTimeout caps a single JSON-RPC request waiting for a diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close.go b/apps/daemon/internal/agent/codex/rpc_close.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/rpc_close.go rename to apps/daemon/internal/agent/codex/rpc_close.go diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go b/apps/daemon/internal/agent/codex/rpc_close_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/rpc_close_test.go rename to apps/daemon/internal/agent/codex/rpc_close_test.go index cc4c18ebb..80e404a85 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/daemon/internal/agent/codex/rpc_close_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" ) func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go b/apps/daemon/internal/agent/codex/rpc_descendants_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go rename to apps/daemon/internal/agent/codex/rpc_descendants_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_process_test.go b/apps/daemon/internal/agent/codex/rpc_process_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/rpc_process_test.go rename to apps/daemon/internal/agent/codex/rpc_process_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_request.go b/apps/daemon/internal/agent/codex/rpc_request.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/rpc_request.go rename to apps/daemon/internal/agent/codex/rpc_request.go diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_test.go b/apps/daemon/internal/agent/codex/rpc_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/rpc_test.go rename to apps/daemon/internal/agent/codex/rpc_test.go index dacd1c9bd..48f15e8f0 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_test.go +++ b/apps/daemon/internal/agent/codex/rpc_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" ) // TestJSONRPCClient_NotificationDispatch verifies the dispatch loop diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_write.go b/apps/daemon/internal/agent/codex/rpc_write.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/rpc_write.go rename to apps/daemon/internal/agent/codex/rpc_write.go diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/daemon/internal/agent/codex/server_requests.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/server_requests.go rename to apps/daemon/internal/agent/codex/server_requests.go index 19c22deb9..5e77e3c9d 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests.go +++ b/apps/daemon/internal/agent/codex/server_requests.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type pendingCodexPermission struct { diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go b/apps/daemon/internal/agent/codex/server_requests_mcp.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/server_requests_mcp.go rename to apps/daemon/internal/agent/codex/server_requests_mcp.go diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go b/apps/daemon/internal/agent/codex/server_requests_mcp_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go rename to apps/daemon/internal/agent/codex/server_requests_mcp_test.go index 7be84ee5e..a72ec3d9e 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests_mcp_test.go +++ b/apps/daemon/internal/agent/codex/server_requests_mcp_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCodexMCPConfirmationUsesPermissionLifecycle(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go b/apps/daemon/internal/agent/codex/server_requests_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/server_requests_test.go rename to apps/daemon/internal/agent/codex/server_requests_test.go index 3c144ba3f..4bae35ed0 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go +++ b/apps/daemon/internal/agent/codex/server_requests_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func newInteractionTestSession(rpc *JSONRPCClient) (*Session, <-chan proto.Envelope) { diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go new file mode 100644 index 000000000..f772866ce --- /dev/null +++ b/apps/daemon/internal/agent/codex/session.go @@ -0,0 +1,504 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +// terminalSendTimeout caps how long the session waits to deliver the +// final done / error envelope on the upstream channel. Matches the +// claudecode + opencode safety net. +const terminalSendTimeout = 2 * time.Second + +// sessionConfig is the cross-cutting knob bag — production callers go +// through Factory which uses defaults. +type sessionConfig struct { + codexBinary string + logger *slog.Logger + killTimeout time.Duration +} + +func defaultSessionConfig() sessionConfig { + return sessionConfig{ + codexBinary: defaultBinary(), + logger: obslog.Bg(), + killTimeout: rpcKillTimeout, + } +} + +// Factory implements agent.Factory for agent_kind="codex". Spawns one +// codex app-server child per prompt. The run stream closes when the turn +// completes; the router retains the child until the conversation's idle +// window expires or cancellation shuts it down sooner. +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultSessionConfig()) +} + +// Session implements agent.Session. State lifecycle: +// +// 1. Preparation initializes RPC and verifies the selected environment. +// 2. Start transfers that RPC and wires notification/server-request handlers. +// 3. thread/start or thread/resume runs (resume falls back to start). +// 4. turn/start delivers the user prompt; subsequent stream notifications +// fan out to proto.Envelope via session_items.go. +// 5. turn/completed emits TypeDone + closes out. Cancel can short-cut +// this by killing the child early. +type Session struct { + retiredTurns map[string]bool + executor *Executor + outputDone chan struct{} + nativeSettled atomic.Bool + settlement agent.TurnSettlement + settlementErr error + terminalCleanupMu sync.Mutex + operationMu sync.Mutex + operations sync.WaitGroup + operationsClosed bool + nativeHome string + subagents *subagentObservations + observeSubagentIdentities bool + functions *functionCalls + observeMessages bool + + observeToolObservations bool + runID string + cfg sessionConfig + out chan<- proto.Envelope + rpc *JSONRPCClient + + cancelCtx context.Context + cancelFn context.CancelFunc + + cancelErr error + cancelOnce sync.Once + cancelReady chan struct{} + cancelled atomic.Bool + terminal atomic.Bool + closeOutOnce sync.Once + outMu sync.RWMutex + outClosed bool + waitDone chan struct{} + cleanup func() + + threadIDMu sync.Mutex + threadID string + steering steeringTurn + + deltaSeq atomic.Uint64 + thinkingSeq atomic.Uint64 + + bufs *ItemBuffers + + usageMu sync.Mutex + latestUsage *TurnUsage + usageTotal TurnUsage + usageBaseline TurnUsage + usageTurnID string + resumeUsageThreadID string + resumeUsageTotal *TurnUsage + resolvedModel string + + finalTextMu sync.Mutex + finalText string + lastErrText string + + interactions *pendingCodexInteractions + outcome cancellationOutcomeState +} + +var _ agent.Session = (*Session)(nil) + +// SubmitPermission completes the deferred Codex app-server request that +// produced the Core permission envelope. +func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { + return s.submitCodexPermission(permID, decision) +} + +// SubmitPromptForUserChoice maps Core's header/answer pairs back to +// Codex's question-id keyed requestUserInput response. +func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + return s.submitCodexUserInput(askID, decision) +} + +// --------------------------------------------------------------------------- +// notification handlers +// --------------------------------------------------------------------------- + +func (s *Session) registerHandlers() { + rpc := s.rpc + + rpc.OnNotification("thread/started", func(_ json.RawMessage) {}) + rpc.OnNotification("turn/started", s.onTurnStarted) + rpc.OnNotification("turn/completed", s.onTurnCompleted) + rpc.OnNotification("turn/failed", s.onTurnFailed) + rpc.OnNotification("item/started", s.onItemStarted) + rpc.OnNotification("item/updated", func(_ json.RawMessage) {}) // silenced + rpc.OnNotification("item/completed", s.onItemCompleted) + rpc.OnNotification("item/agentMessage/delta", s.onAgentDelta) + rpc.OnNotification("item/commandExecution/outputDelta", s.onCommandOutput) + rpc.OnNotification("item/reasoning/textDelta", s.onReasoningDelta) + rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) + rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) + rpc.OnNotification("error", s.onErrorNotif) + + s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) + s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) + s.onServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) + // Older app-server releases used this unseparated method name. + s.onServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) + s.onServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) + s.onServerRequest("item/tool/call", s.handleFunctionCall) + s.onServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) +} + +func (s *Session) onTurnStarted(raw json.RawMessage) { + var p TurnStartedNotification + if json.Unmarshal(raw, &p) == nil { + s.beginRootTurn(p.ThreadID, p.Turn.ID) + } +} + +func (s *Session) onReasoningDelta(raw json.RawMessage) { + var p AgentMessageDeltaNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { + return + } + _ = FoldDeltaIntoBuffer(s.bufs, "reasoning", p.ItemID, p.Delta) + seq := s.thinkingSeq.Add(1) + env, err := proto.NewEnvelope(proto.TypeThinking, s.runID, proto.ThinkingPayload{Text: p.Delta, Sequence: seq}) + if err != nil { + return + } + s.trySend(env) +} + +func (s *Session) onTurnCompleted(raw json.RawMessage) { + s.outcome.notificationMu.Lock() + defer s.outcome.notificationMu.Unlock() + var p TurnCompletedNotification + if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { + return + } + s.nativeSettled.Store(true) + s.stopSteering() + + usage := p.Turn.Usage + if usage == nil { + s.usageMu.Lock() + usage = s.latestUsage + s.usageMu.Unlock() + } + if usage != nil { + s.usageMu.Lock() + s.latestUsage = usage + s.usageMu.Unlock() + s.emitUsage(*usage) + } + + status := strings.ToLower(p.Turn.Status) + finalText := s.takeFinalText() + errText := s.takeLastErrText() + // Always log the turn outcome — operators need this when a prompt + // "completes" with no agent message (e.g. codex bailed before the + // model ran because the sandbox mode was misinterpreted as + // read-only) so the empty body in the upstream Done frame can be + // correlated with the turn status that produced it. + s.cfg.logger.Info("codex: turn/completed", + "run_id", s.runID, + "turn_id", p.Turn.ID, + "status", p.Turn.Status, + "final_text_len", len(finalText), + "buffered_err_text_len", len(errText), + "raw_payload", string(raw)) + if status == "failed" { + // Body precedence on failure: + // 1. agent's final text (rare on hard failures but exists for + // partial completions that still surface a message) + // 2. codex's turn.error.message — this is where gateway / + // provider errors land (e.g. an upstream gateway's "X-Sub-Module is + // not allowed for this API key"). Without forwarding it + // the upstream connector reports "empty final output" and + // operators can't see why a key was rejected. + // 3. buffered text from the "error" notification stream + // (sandbox warnings, late stream packets) — last because + // it's noisier than turn.error. + body := finalText + if turnErrMsg := turnErrorMessage(p.Turn.Error); turnErrMsg != "" { + body = appendOnNewline(body, turnErrMsg) + } + if errText != "" { + body = appendOnNewline(body, errText) + } + s.emitTerminalFailure(body, true, classifyTurnError(p.Turn.Error)) + s.finishAfterTerminal() + return + } + var completedAt *int64 + if status == "completed" { + completedAt = nativeMilliseconds(p.Turn.CompletedAt) + } + s.emitDoneAt(finalText, usage, completedAt) + s.finishAfterTerminal() +} + +// turnErrorMessage extracts a human-readable error string from +// codex's TurnError. Some gateways (an OpenAI-Responses-style proxy +// is one) JSON-encode the upstream error body and stuff it +// into Message verbatim; in that case unwrap one layer so the +// operator sees the inner code/message instead of escaped JSON. +func turnErrorMessage(te *TurnError) string { + if te == nil { + return "" + } + raw := strings.TrimSpace(te.Message) + if raw == "" { + return "" + } + // Try to peel off a {"error":{"code","message","type"}} wrapper. + var inner struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &inner); err == nil && inner.Error.Message != "" { + if inner.Error.Code != "" { + return fmt.Sprintf("%s: %s", inner.Error.Code, inner.Error.Message) + } + return inner.Error.Message + } + return raw +} + +func appendOnNewline(base, extra string) string { + if base == "" { + return extra + } + if extra == "" { + return base + } + return base + "\n\n" + extra +} + +func (s *Session) onTurnFailed(raw json.RawMessage) { + var p TurnCompletedNotification + if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { + return + } + // turn/failed carries no payload detail today; the actual cause + // usually arrived earlier on the "error" notification stream and is + // already buffered in lastErrText. Log both so post-mortems can + // correlate the failure to whatever upstream codex saw. + s.cfg.logger.Warn("codex: turn/failed received", + "run_id", s.runID, + "turn_id", p.Turn.ID, + "turn_status", p.Turn.Status, + "last_err_text_present", s.peekLastErrText() != "") + s.emitTerminal("codex: turn failed", true) + s.finishAfterTerminal() +} + +func (s *Session) onErrorNotif(raw json.RawMessage) { + var p ErrorNotification + if err := json.Unmarshal(raw, &p); err != nil { + return + } + if !s.isRootTurn(p.ThreadID, p.TurnID) { + return + } + if p.Error != nil { + p.Message = turnErrorMessage(p.Error) + } + if p.Message == "" { + return + } + // Always log: codex's `error` notification is the *only* channel that + // surfaces gateway / model-provider failures (401 on a custom header, + // 400 from Azure missing api-version, etc.). Buffering it for the + // eventual turn/completed message body is correct, but without a log + // the daemon shows 13s of silence then a TypeError that the upstream + // can't decode. + s.cfg.logger.Warn("codex: error notification received", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message", p.Message) + s.finalTextMu.Lock() + s.lastErrText = p.Message + s.finalTextMu.Unlock() +} + +// peekLastErrText reads lastErrText without consuming it, used by +// loggers that want to record "we have a buffered upstream error" +// without racing with the takeLastErrText path that emitDone uses. +func (s *Session) peekLastErrText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + return s.lastErrText +} + +// --------------------------------------------------------------------------- +// envelope emit helpers +// --------------------------------------------------------------------------- + +func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int64) { + if !s.terminal.CompareAndSwap(false, true) { + return + } + s.stopSteering() + s.stopFunctionCalls() + doneMeta := map[string]any{} + if tid := s.currentThreadID(); tid != "" { + doneMeta[proto.DoneMetaAgentSessionID] = tid + doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" + } + payload := proto.DonePayload{Content: content, Metadata: doneMeta, SourceCompletedAtMS: completedAt} + if usage != nil { + payload.Usage = s.usagePayload(*usage) + } + payload = s.rememberOutcome(payload) + env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) + if err != nil { + return + } + s.sendTerminal(env) +} + +func (s *Session) emitUsage(u TurnUsage) { + env, err := proto.NewEnvelope(proto.TypeUsage, s.runID, proto.UsagePayload{ + Usage: s.usagePayload(u), + }) + if err != nil { + return + } + s.trySend(env) +} + +func (s *Session) emitTerminal(message string, asError bool) { + s.emitTerminalFailure(message, asError, proto.ErrorPayload{}) +} + +func (s *Session) emitTerminalFailure(message string, asError bool, failure proto.ErrorPayload) { + if s.executor != nil { + defer s.finishAfterTerminal() + } + if !s.terminal.CompareAndSwap(false, true) { + return + } + s.stopSteering() + s.stopFunctionCalls() + // Always log: this is the only place the daemon decides "the prompt is + // over, here's what went wrong (if anything)". Without this, post- + // mortem requires correlating server-side TypeError frames against + // daemon timestamps with no message body anywhere. + if asError { + s.cfg.logger.Warn("codex: emitting terminal error", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message", message) + } else { + s.cfg.logger.Info("codex: emitting terminal done", + "run_id", s.runID, + "thread_id", s.currentThreadID(), + "message_len", len(message)) + } + var events []proto.Envelope + if asError { + failure.Error = message + env, err := proto.NewEnvelope(proto.TypeError, s.runID, failure) + if err == nil { + events = append(events, env) + } + } + doneMeta := map[string]any{} + if tid := s.currentThreadID(); tid != "" { + doneMeta[proto.DoneMetaAgentSessionID] = tid + doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" + } + payload := proto.DonePayload{ + Content: message, + Metadata: doneMeta, + } + payload = s.rememberOutcome(payload) + env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) + if err != nil { + return + } + s.sendTerminal(append(events, env)...) +} + +func (s *Session) closeOut() { + s.stopSteering() + s.closeOutOnce.Do(func() { + s.outMu.Lock() + s.outClosed = true + close(s.out) + if s.outputDone != nil { + close(s.outputDone) + } + s.outMu.Unlock() + }) +} + +func (s *Session) finishAfterTerminal() { + if s.subagents == nil { + s.closeOut() + } +} + +// --------------------------------------------------------------------------- +// small accessors +// --------------------------------------------------------------------------- + +func (s *Session) currentThreadID() string { + s.threadIDMu.Lock() + defer s.threadIDMu.Unlock() + return s.threadID +} + +func (s *Session) setThreadID(id string) { + s.threadIDMu.Lock() + if s.threadID == "" { + s.threadID = id + } + s.threadIDMu.Unlock() +} + +func (s *Session) appendFinalText(text string) { + s.finalTextMu.Lock() + if s.finalText != "" { + s.finalText = s.finalText + "\n\n" + text + } else { + s.finalText = text + } + s.finalTextMu.Unlock() +} + +func (s *Session) takeFinalText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + t := s.finalText + s.finalText = "" + return t +} + +func (s *Session) takeLastErrText() string { + s.finalTextMu.Lock() + defer s.finalTextMu.Unlock() + e := s.lastErrText + s.lastErrText = "" + return e +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel.go b/apps/daemon/internal/agent/codex/session_cancel.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/session_cancel.go rename to apps/daemon/internal/agent/codex/session_cancel.go diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel_test.go b/apps/daemon/internal/agent/codex/session_cancel_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/session_cancel_test.go rename to apps/daemon/internal/agent/codex/session_cancel_test.go index 023b708cb..b5879197b 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_cancel_test.go +++ b/apps/daemon/internal/agent/codex/session_cancel_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type interruptRequest struct { diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go b/apps/daemon/internal/agent/codex/session_cancel_write_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/session_cancel_write_test.go rename to apps/daemon/internal/agent/codex/session_cancel_write_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/session_command_output.go b/apps/daemon/internal/agent/codex/session_command_output.go similarity index 87% rename from apps/parsar-daemon/internal/agent/codex/session_command_output.go rename to apps/daemon/internal/agent/codex/session_command_output.go index e48de64cd..600e4fdad 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_command_output.go +++ b/apps/daemon/internal/agent/codex/session_command_output.go @@ -3,7 +3,7 @@ package codex import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) onCommandOutput(raw json.RawMessage) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_command_output_test.go b/apps/daemon/internal/agent/codex/session_command_output_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/session_command_output_test.go rename to apps/daemon/internal/agent/codex/session_command_output_test.go index 4524408fb..4f86ccbd0 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_command_output_test.go +++ b/apps/daemon/internal/agent/codex/session_command_output_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCommandOutputRequiresOptInAndRootTurn(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_items.go b/apps/daemon/internal/agent/codex/session_items.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_items.go rename to apps/daemon/internal/agent/codex/session_items.go index f0b88a853..3cb3db01d 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_items.go +++ b/apps/daemon/internal/agent/codex/session_items.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // ItemBuffers holds the per-itemId text accumulators used to fold diff --git a/apps/parsar-daemon/internal/agent/codex/session_items_test.go b/apps/daemon/internal/agent/codex/session_items_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/session_items_test.go rename to apps/daemon/internal/agent/codex/session_items_test.go index 500653e1c..8ba3334b7 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_items_test.go +++ b/apps/daemon/internal/agent/codex/session_items_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestDispatchStartedItem_Bash(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/session_knowledge_test.go b/apps/daemon/internal/agent/codex/session_knowledge_test.go new file mode 100644 index 000000000..1edc883b4 --- /dev/null +++ b/apps/daemon/internal/agent/codex/session_knowledge_test.go @@ -0,0 +1,42 @@ +package codex + +import ( + "context" + "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "testing" + "time" +) + +func TestResumeRefreshesReferenceContext(t *testing.T) { + for _, prompt := range []string{"updated knowledge reference", ""} { + t.Run(prompt, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "knowledge-test")}} + done := make(chan error, 1) + go func() { done <- s.resumeThread("same-thread", SessionPlan{SystemPrompt: prompt}) }() + var request struct { + ID string `json:"id"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if value, ok := request.Params["developerInstructions"]; !ok || value != prompt { + t.Fatal("resume did not replace the old instructions") + } + if request.Params["threadId"] != "same-thread" { + t.Fatal("lost history") + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]string{"id": "same-thread"}}}); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_log_test.go b/apps/daemon/internal/agent/codex/session_log_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_log_test.go rename to apps/daemon/internal/agent/codex/session_log_test.go index 5c307ac4d..d0ec7977c 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_log_test.go +++ b/apps/daemon/internal/agent/codex/session_log_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // TestEmitTerminal_LogsErrorMessage pins the diagnostic invariant: diff --git a/apps/parsar-daemon/internal/agent/codex/session_messages.go b/apps/daemon/internal/agent/codex/session_messages.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/session_messages.go rename to apps/daemon/internal/agent/codex/session_messages.go index 42fbb43f2..4e1061dc6 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_messages.go +++ b/apps/daemon/internal/agent/codex/session_messages.go @@ -3,7 +3,7 @@ package codex import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) onAgentDelta(raw json.RawMessage) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_messages_test.go b/apps/daemon/internal/agent/codex/session_messages_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/session_messages_test.go rename to apps/daemon/internal/agent/codex/session_messages_test.go index dbb7d1e49..b17ed114e 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_messages_test.go +++ b/apps/daemon/internal/agent/codex/session_messages_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestMessageObservationIsOptInAndKeepsNativeBoundaries(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications.go b/apps/daemon/internal/agent/codex/session_notifications.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/session_notifications.go rename to apps/daemon/internal/agent/codex/session_notifications.go diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go rename to apps/daemon/internal/agent/codex/session_notifications_rpc_test.go index 147985ca8..f7b4d0f26 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_notifications_rpc_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestRootRPCNotificationOrdering(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_notifications_test.go rename to apps/daemon/internal/agent/codex/session_notifications_test.go index 1c82ac51a..0df73e0e1 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestRootNotificationIsolation(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_output.go b/apps/daemon/internal/agent/codex/session_output.go similarity index 89% rename from apps/parsar-daemon/internal/agent/codex/session_output.go rename to apps/daemon/internal/agent/codex/session_output.go index 012af3d60..a4cba2eca 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_output.go +++ b/apps/daemon/internal/agent/codex/session_output.go @@ -3,7 +3,7 @@ package codex import ( "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) trySend(env proto.Envelope) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go similarity index 94% rename from apps/parsar-daemon/internal/agent/codex/session_plan.go rename to apps/daemon/internal/agent/codex/session_plan.go index 8591f34b8..1ab1cb76c 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, []string, error) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go similarity index 95% rename from apps/parsar-daemon/internal/agent/codex/session_policy_test.go rename to apps/daemon/internal/agent/codex/session_policy_test.go index 869b47ecf..e21e793c9 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go +++ b/apps/daemon/internal/agent/codex/session_policy_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/session_run.go rename to apps/daemon/internal/agent/codex/session_run.go index a482b8585..9051ea3c4 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering.go b/apps/daemon/internal/agent/codex/session_steering.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/session_steering.go rename to apps/daemon/internal/agent/codex/session_steering.go index f0d6be931..53fabc084 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering.go +++ b/apps/daemon/internal/agent/codex/session_steering.go @@ -7,8 +7,8 @@ import ( "fmt" "sync" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type steeringTurn struct { diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go rename to apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go index 46bad9b45..c886439de 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) func TestSteeringReceiptTimeoutAndCompletionKeepProcessAlive(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go b/apps/daemon/internal/agent/codex/session_steering_receipt_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go rename to apps/daemon/internal/agent/codex/session_steering_receipt_test.go index 5f6fab215..bb14579d9 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering_receipt_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_receipt_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestDurableSteeringBypassesOnlyNativeResponseDeadline(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering_test.go b/apps/daemon/internal/agent/codex/session_steering_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/codex/session_steering_test.go rename to apps/daemon/internal/agent/codex/session_steering_test.go index fa1bd0746..1fe0fcc47 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSteeringUsesNativeActiveTurnAndReceipt(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_thread.go b/apps/daemon/internal/agent/codex/session_thread.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/session_thread.go rename to apps/daemon/internal/agent/codex/session_thread.go diff --git a/apps/daemon/internal/agent/codex/session_tools.go b/apps/daemon/internal/agent/codex/session_tools.go new file mode 100644 index 000000000..5a40b45c3 --- /dev/null +++ b/apps/daemon/internal/agent/codex/session_tools.go @@ -0,0 +1,38 @@ +package codex + +import ( + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawMessage) { + var native struct { + Item json.RawMessage `json:"item"` + } + if s.observeToolObservations { + if err := json.Unmarshal(notification, &native); err != nil { + return + } + } + for _, event := range events { + if (s.observeToolObservations) && event.Type == proto.TypeToolCall { + var tool proto.ToolCallPayload + if err := event.DecodePayload(&tool); err != nil { + return + } + var err error + tool.Observation, err = normalizeToolObservation(tool.ID, tool.Stage, native.Item) + if err != nil { + s.emitTerminal("codex: invalid tool observation", true) + return + } + payload, err := json.Marshal(tool) + if err != nil { + return + } + event.Payload = payload + } + s.trySend(event) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools_test.go b/apps/daemon/internal/agent/codex/session_tools_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/session_tools_test.go rename to apps/daemon/internal/agent/codex/session_tools_test.go index f7fff2c95..2eff526bb 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_tools_test.go +++ b/apps/daemon/internal/agent/codex/session_tools_test.go @@ -6,7 +6,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func toolSnapshotFixtures() []string { diff --git a/apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go b/apps/daemon/internal/agent/codex/session_turn_error_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/session_turn_error_test.go rename to apps/daemon/internal/agent/codex/session_turn_error_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage.go b/apps/daemon/internal/agent/codex/session_usage.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/session_usage.go rename to apps/daemon/internal/agent/codex/session_usage.go index 17e672284..4d2ea1d5d 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_usage.go +++ b/apps/daemon/internal/agent/codex/session_usage.go @@ -3,7 +3,7 @@ package codex import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) beginUsageTurn(turnID string) { diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go b/apps/daemon/internal/agent/codex/session_usage_live_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go rename to apps/daemon/internal/agent/codex/session_usage_live_test.go index 0672b6433..58ccc1d44 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go +++ b/apps/daemon/internal/agent/codex/session_usage_live_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const activeUsageNotification = `{"method":"thread/tokenUsage/updated","params":{"threadId":"thread","turnId":"turn","tokenUsage":{"total":{"inputTokens":30,"cachedInputTokens":4,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":40}}}}` diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage_test.go b/apps/daemon/internal/agent/codex/session_usage_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/session_usage_test.go rename to apps/daemon/internal/agent/codex/session_usage_test.go index eb7f93c1f..bdaeec6b0 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_usage_test.go +++ b/apps/daemon/internal/agent/codex/session_usage_test.go @@ -7,7 +7,7 @@ import ( "log/slog" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestNativeTokenUsageAcrossRuns(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go new file mode 100644 index 000000000..ae8ead08c --- /dev/null +++ b/apps/daemon/internal/agent/codex/skills.go @@ -0,0 +1,52 @@ +package codex + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func effectiveAgentStateKey(req proto.PromptRequestPayload) string { + if strings.TrimSpace(req.AgentStateKey) != "" { + return req.AgentStateKey + } + if id := strings.TrimSpace(req.ConversationID); id != "" { + return "_legacy_conversation/" + id + "/codex" + } + if id := strings.TrimSpace(req.RunID); id != "" { + return "_legacy_run/" + id + "/codex" + } + return "" +} + +func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (string, error) { + rawSkills := req.AgentOptions["skills"] + root, err := agent.ManagedSkillsRoot("codex", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return "", fmt.Errorf("codex: resolve managed skills root: %w", err) + } + result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) + if err != nil { + return "", fmt.Errorf("codex: install skills: %w", err) + } + for _, warning := range result.Warnings { + logger.Warn("codex: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(result.SkillDirs) == 0 { + return "", nil + } + return root, nil +} + +func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { + if len(roots) == 0 { + return nil + } + _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}) + return err +} diff --git a/apps/daemon/internal/agent/codex/skills_test.go b/apps/daemon/internal/agent/codex/skills_test.go new file mode 100644 index 000000000..35d031ba5 --- /dev/null +++ b/apps/daemon/internal/agent/codex/skills_test.go @@ -0,0 +1,76 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + + result := make(chan error, 1) + go func() { + result <- setSkillExtraRoots(context.Background(), client.JSONRPCClient, []string{"/managed/skills"}) + }() + + decoder := json.NewDecoder(server.FromClient) + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params SkillsExtraRootsSetParams `json:"params"` + } + if err := decoder.Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.Method != "skills/extraRoots/set" { + t.Fatalf("method = %q", request.Method) + } + if len(request.Params.ExtraRoots) != 1 || request.Params.ExtraRoots[0] != "/managed/skills" { + t.Fatalf("params = %+v", request.Params) + } + response, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) + if _, err := server.ToClient.Write(append(response, '\n')); err != nil { + t.Fatalf("write response: %v", err) + } + if err := <-result; err != nil { + t.Fatalf("setSkillExtraRoots: %v", err) + } +} + +func TestPrepareManagedSkillsPrunesWhenPayloadOmitsSkills(t *testing.T) { + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + stale := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills", "stale") + if err := os.MkdirAll(stale, 0o755); err != nil { + t.Fatal(err) + } + + root, err := prepareManagedSkills(context.Background(), nil, proto.PromptRequestPayload{ + AgentStateKey: "conv-1/agent-1/codex", + }) + if err != nil { + t.Fatalf("prepareManagedSkills: %v", err) + } + if root != "" { + t.Fatalf("root = %q, want empty", root) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale skill still exists: %v", err) + } +} + +func TestEffectiveAgentStateKeyFallsBackToConversation(t *testing.T) { + got := effectiveAgentStateKey(proto.PromptRequestPayload{ + ConversationID: "conv-legacy", + RunID: "run-ignored", + }) + if got != "_legacy_conversation/conv-legacy/codex" { + t.Fatalf("state key = %q", got) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_cancel.go b/apps/daemon/internal/agent/codex/subagent_cancel.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagent_cancel.go rename to apps/daemon/internal/agent/codex/subagent_cancel.go diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go b/apps/daemon/internal/agent/codex/subagent_cancel_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go rename to apps/daemon/internal/agent/codex/subagent_cancel_test.go index e2ff66b20..f953f65bb 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go +++ b/apps/daemon/internal/agent/codex/subagent_cancel_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSubagentCancelDeadlineRetainsOwnerAndRetry(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/subagent_coordination.go b/apps/daemon/internal/agent/codex/subagent_coordination.go new file mode 100644 index 000000000..43181de9b --- /dev/null +++ b/apps/daemon/internal/agent/codex/subagent_coordination.go @@ -0,0 +1,64 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func subagentCoordination(raw json.RawMessage, actor string) (*proto.SubagentCoordinationPayload, error) { + var item struct { + subagentCollaboration + Model *string `json:"model"` + ReasoningEffort *string `json:"reasoningEffort"` + } + if json.Unmarshal(raw, &item) != nil { + return nil, errors.New("codex: invalid subagent coordination Item") + } + if item.Type != "collabAgentToolCall" { + return nil, nil + } + kinds := map[string]string{"spawnAgent": "create_subagent_call", "sendInput": "send_subagent_input_call", "wait": "wait_for_subagents_call", "closeAgent": "close_subagent_call", "resumeAgent": "resume_subagent_call"} + kind, ok := kinds[item.Tool] + if !ok { + return nil, errors.New("codex: unsupported subagent coordination tool") + } + status := observationStatus(item.Status, "after") + if item.Status == "inProgress" { + status = "in_progress" + } + return &proto.SubagentCoordinationPayload{ID: item.ID, Kind: kind, Status: status, ActorID: actor, Recipients: item.Receivers, Text: item.Prompt, Model: item.Model, ReasoningEffort: item.ReasoningEffort}, nil +} + +func (s *Session) publishSubagentCoordination(ctx context.Context, h subagentHistory, known map[string]bool) error { + root := s.currentThreadID() + + s.steering.mu.Lock() + rootTurn := s.steering.id + s.steering.mu.Unlock() + for _, turn := range h.Turns { + if h.ID == root && turn.ID != rootTurn { + continue + } + for _, raw := range turn.Items { + payload, err := subagentCoordination(raw, "") + if err != nil { + return err + } + if payload == nil { + continue + } + for _, id := range payload.Recipients { + if (id == root || !known[id]) && payload.Status != "failed" { + return errors.New("codex: coordination recipient has no verified subagent identity") + } + } + if err := s.sendSubagentFact(ctx, proto.TypeSubagentCoordination, turn.ID+":"+payload.ID, payload); err != nil { + return err + } + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_history.go b/apps/daemon/internal/agent/codex/subagent_history.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagent_history.go rename to apps/daemon/internal/agent/codex/subagent_history.go diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go b/apps/daemon/internal/agent/codex/subagent_metadata.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagent_metadata.go rename to apps/daemon/internal/agent/codex/subagent_metadata.go diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations.go b/apps/daemon/internal/agent/codex/subagent_observations.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/subagent_observations.go rename to apps/daemon/internal/agent/codex/subagent_observations.go index 9c1a68cc3..29d1dcaf3 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_observations.go +++ b/apps/daemon/internal/agent/codex/subagent_observations.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const subagentLookupTimeout = 3 * time.Second diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go b/apps/daemon/internal/agent/codex/subagent_observations_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go rename to apps/daemon/internal/agent/codex/subagent_observations_test.go index 181da6d9f..6d10dd0a8 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/daemon/internal/agent/codex/subagent_observations_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type subagentFixture struct { diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go b/apps/daemon/internal/agent/codex/subagent_profile.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/subagent_profile.go rename to apps/daemon/internal/agent/codex/subagent_profile.go index 648e97071..8342e5538 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go +++ b/apps/daemon/internal/agent/codex/subagent_profile.go @@ -8,7 +8,7 @@ import ( stdstrconv "strconv" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPayload) error { diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go b/apps/daemon/internal/agent/codex/subagent_profile_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go rename to apps/daemon/internal/agent/codex/subagent_profile_test.go index fb903f2c3..5e9df85a0 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/daemon/internal/agent/codex/subagent_profile_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_rollout.go b/apps/daemon/internal/agent/codex/subagent_rollout.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/subagent_rollout.go rename to apps/daemon/internal/agent/codex/subagent_rollout.go index f63f264f7..20e063c2a 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_rollout.go +++ b/apps/daemon/internal/agent/codex/subagent_rollout.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type subagentEffect struct { diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_rollout_test.go b/apps/daemon/internal/agent/codex/subagent_rollout_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagent_rollout_test.go rename to apps/daemon/internal/agent/codex/subagent_rollout_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go b/apps/daemon/internal/agent/codex/subagent_snapshots.go similarity index 99% rename from apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go rename to apps/daemon/internal/agent/codex/subagent_snapshots.go index e890da6cc..9f3eda46c 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go +++ b/apps/daemon/internal/agent/codex/subagent_snapshots.go @@ -6,7 +6,7 @@ import ( "errors" "sort" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) snapshotSubagents(ctx context.Context) (bool, error) { diff --git a/apps/parsar-daemon/internal/agent/codex/subagents.go b/apps/daemon/internal/agent/codex/subagents.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagents.go rename to apps/daemon/internal/agent/codex/subagents.go diff --git a/apps/parsar-daemon/internal/agent/codex/subagents_test.go b/apps/daemon/internal/agent/codex/subagents_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/subagents_test.go rename to apps/daemon/internal/agent/codex/subagents_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go b/apps/daemon/internal/agent/codex/terminal_cleanup.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go rename to apps/daemon/internal/agent/codex/terminal_cleanup.go diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go b/apps/daemon/internal/agent/codex/terminal_cleanup_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go rename to apps/daemon/internal/agent/codex/terminal_cleanup_test.go index ace5c1a17..c0a208c6b 100644 --- a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go +++ b/apps/daemon/internal/agent/codex/terminal_cleanup_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/tool_observations.go b/apps/daemon/internal/agent/codex/tool_observations.go new file mode 100644 index 000000000..bdc2bc57b --- /dev/null +++ b/apps/daemon/internal/agent/codex/tool_observations.go @@ -0,0 +1,125 @@ +package codex + +import ( + "encoding/json" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Raw fields bypass ThreadItem's legacy any fields to preserve structured values. +type toolObservationSource struct { + ThreadItem + Cwd *string `json:"cwd"` + Arguments json.RawMessage `json:"arguments"` + Changes json.RawMessage `json:"changes"` + Output *string `json:"aggregatedOutput"` + DurationMS *int64 `json:"durationMs"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ContentItems *[]functionContent `json:"contentItems"` + Success *bool `json:"success"` + Action *proto.ToolWebSearchAction `json:"action"` +} + +func normalizeToolObservation(id, stage string, raw json.RawMessage) (*proto.ToolObservation, error) { + var n toolObservationSource + if err := json.Unmarshal(raw, &n); err != nil { + return nil, err + } + if n.ID != id || id == "" || (stage != "before" && stage != "after") { + return nil, errors.New("invalid native tool identity or stage") + } + out := &proto.ToolObservation{Status: observationStatus(n.Status, stage)} + switch n.Type { + case "commandExecution": + if n.Command == "" { + return nil, errors.New("missing command") + } + out.Kind, out.Command, out.Cwd, out.DurationMS = "command", n.Command, n.Cwd, n.DurationMS + if n.ExitCode != nil { + value := int64(*n.ExitCode) + out.ExitCode = &value + } + if n.Output != nil { + out.Output, _ = json.Marshal(*n.Output) + } + case "mcpToolCall": + if n.Server == "" || n.Tool == "" { + return nil, errors.New("missing MCP identity") + } + out.Kind, out.Server, out.Name = "mcp", n.Server, n.Tool + out.Arguments, out.Output, out.Error = n.Arguments, n.Result, n.Error + case "dynamicToolCall": + if n.Tool == "" { + return nil, errors.New("missing function identity") + } + out.Kind, out.Name, out.Arguments = "function", n.Tool, n.Arguments + if n.Namespace != "" { + out.Name = n.Namespace + "::" + n.Tool + } + if stage == "after" { + if n.Success != nil && !*n.Success { + out.Status = "failed" + } + if n.ContentItems != nil { + content := make([]proto.InputContent, 0, len(*n.ContentItems)) + for _, part := range *n.ContentItems { + var value proto.InputContent + switch part.Type { + case "inputText": + value = proto.InputContent{Type: "input_text", Text: part.Text} + case "inputImage": + value = proto.InputContent{Type: "input_image", ImageURL: part.ImageURL} + default: + return nil, errors.New("unsupported function result content") + } + content = append(content, value) + } + if err := (proto.FunctionResultPayload{Content: content}).ValidateContent(); err != nil { + return nil, err + } + out.Content = &content + } + } + case "fileChange": + out.Kind, out.Name = "function", "apply_patch" + changes := n.Changes + if len(changes) == 0 { + changes = json.RawMessage("null") + } + out.Arguments, _ = json.Marshal(struct { + Changes json.RawMessage `json:"changes"` + }{changes}) + case "webSearch": + out.Kind, out.Action = "web_search", n.Action + if out.Action != nil { + switch out.Action.Type { + case "openPage": + out.Action.Type = "open_page" + case "findInPage": + out.Action.Type = "find_in_page" + case "search", "open_page", "find_in_page", "other": + default: + return nil, errors.New("unsupported web action") + } + } + default: + return nil, errors.New("unsupported native tool observation") + } + return out, nil +} + +func observationStatus(native, stage string) string { + if stage == "before" { + return "in_progress" + } + switch native { + case "", "completed": + return "completed" + case "failed", "declined": + return "failed" + default: + return "incomplete" + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_observations_test.go b/apps/daemon/internal/agent/codex/tool_observations_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/tool_observations_test.go rename to apps/daemon/internal/agent/codex/tool_observations_test.go diff --git a/apps/daemon/internal/agent/codex/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go new file mode 100644 index 000000000..a2a092928 --- /dev/null +++ b/apps/daemon/internal/agent/codex/unsupported.go @@ -0,0 +1,42 @@ +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 +} + +func (s *Prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported +} + +func (s *Prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported +} + +func (s *Prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported +} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go new file mode 100644 index 000000000..0f0936197 --- /dev/null +++ b/apps/daemon/internal/agent/codex/unsupported_test.go @@ -0,0 +1,48 @@ +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), (*Prepared)(nil)} { + result, err := owner.ReadWorkspaceFile(ctx, secret, 1) + check(err) + if len(result.Data) != 0 || result.Truncated { + t.Fatal("unsupported read fabricated data") + } + } + for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) + check(err) + if len(result.Entries) != 0 || result.Truncated { + t.Fatal("unsupported listing fabricated entries") + } + } + for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) + check(err) + if result.SizeBytes != 0 { + t.Fatal("unsupported write fabricated receipt") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/verbosity_test.go b/apps/daemon/internal/agent/codex/verbosity_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/verbosity_test.go rename to apps/daemon/internal/agent/codex/verbosity_test.go diff --git a/apps/daemon/internal/agent/codex/version.go b/apps/daemon/internal/agent/codex/version.go new file mode 100644 index 000000000..b48db362c --- /dev/null +++ b/apps/daemon/internal/agent/codex/version.go @@ -0,0 +1,60 @@ +package codex + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" +) + +// InstallURL points operators at the Codex install instructions when +// 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() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish an +// install problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("codex CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). Matches +// the CheckCLIAvailable signature of the claudecode and opencode adapters +// so connect.go's preflight loop treats every engine uniformly. +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + if strings.TrimSpace(binary) == "" { + binary = defaultBinary() + } + if _, lookErr := exec.LookPath(binary); lookErr != nil { + return "", fmt.Errorf("%w: %s", ErrCLINotFound, binary) + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, binary, "--version") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("codex --version failed: %s", msg) + } + out := strings.TrimSpace(stdout.String()) + if i := strings.IndexByte(out, '\n'); i >= 0 { + out = out[:i] + } + if out == "" { + return "", fmt.Errorf("codex --version returned empty output") + } + return out, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/web_search_test.go b/apps/daemon/internal/agent/codex/web_search_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/codex/web_search_test.go rename to apps/daemon/internal/agent/codex/web_search_test.go diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go new file mode 100644 index 000000000..974f06824 --- /dev/null +++ b/apps/daemon/internal/agent/configuration_test.go @@ -0,0 +1,78 @@ +package agent_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" +) + +func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { + registry := agent.NewRegistry() + configuration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "responses"}}} + calls := 0 + expected := errors.New("native entry reached") + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, configuration, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + calls++ + return nil, expected + }) + registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + calls++ + return nil, expected + }) + registry.RegisterPreparation("fixture", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + calls++ + return nil, expected + }) + configuration.Providers[0].Protocol = "anthropic" + copy, err := registry.Configuration("fixture") + if err != nil { + t.Fatal(err) + } + copy.Providers[0].Protocol = "anthropic" + factory, _ := registry.Resolve("fixture") + executor, _ := registry.ResolveExecutor("fixture") + preparation, _ := registry.ResolvePreparation("fixture") + entries := []func(proto.PromptRequestPayload) error{ + func(req proto.PromptRequestPayload) error { _, err := factory(t.Context(), req, nil); return err }, + func(req proto.PromptRequestPayload) error { _, err := executor(t.Context(), req); return err }, + func(req proto.PromptRequestPayload) error { _, err := preparation(t.Context(), req); return err }, + } + for _, entry := range entries { + for _, options := range []map[string]any{ + {"model": nil}, + {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"harness_config": map[string]any{"unknown": "private-sentinel"}}, + } { + before := calls + err := entry(proto.PromptRequestPayload{AgentOptions: options}) + if err == nil || errors.Is(err, expected) || calls != before || strings.Contains(err.Error(), "private-sentinel") { + t.Fatal("invalid configuration reached native entry or leaked values") + } + } + if err := entry(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) { + t.Fatal("bound declaration was lost or mutated", err) + } + } + if calls != 3 { + t.Fatal("unexpected native calls", calls) + } + if _, err := registry.Configuration("missing"); err == nil { + t.Fatal("undeclared configuration inferred") + } +} + +func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("invalid configuration registered") + } + }() + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}, stubFactory("fixture")) +} diff --git a/apps/parsar-daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/contract_declarations_test.go rename to apps/daemon/internal/agent/contract_declarations_test.go diff --git a/apps/parsar-daemon/internal/agent/contracttest/text.go b/apps/daemon/internal/agent/contracttest/text.go similarity index 97% rename from apps/parsar-daemon/internal/agent/contracttest/text.go rename to apps/daemon/internal/agent/contracttest/text.go index 7cffea179..5c0d76e4a 100644 --- a/apps/parsar-daemon/internal/agent/contracttest/text.go +++ b/apps/daemon/internal/agent/contracttest/text.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // TextFixture supplies a prepared Executor and deterministic native inputs. diff --git a/apps/parsar-daemon/internal/agent/functions.go b/apps/daemon/internal/agent/functions.go similarity index 100% rename from apps/parsar-daemon/internal/agent/functions.go rename to apps/daemon/internal/agent/functions.go diff --git a/apps/parsar-daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go similarity index 98% rename from apps/parsar-daemon/internal/agent/harness.go rename to apps/daemon/internal/agent/harness.go index c5b995677..80afd7021 100644 --- a/apps/parsar-daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -20,7 +20,7 @@ // verified native behavior. Built-in registration lives in internal/cli. // // Runtime registration and Core service qualification remain separate. A public -// Harness also needs a profile in services/agents-api/internal/engine; advertising +// Harness also needs a profile in services/core/internal/engine; advertising // a capability cannot authorize it. Requests, events and capability descriptors // use the existing internal/agentdaemon/proto types. package agent @@ -28,8 +28,8 @@ package agent import ( "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) // Model configuration has one shared contract, authored in diff --git a/apps/parsar-daemon/internal/agent/installation.go b/apps/daemon/internal/agent/installation.go similarity index 100% rename from apps/parsar-daemon/internal/agent/installation.go rename to apps/daemon/internal/agent/installation.go diff --git a/apps/parsar-daemon/internal/agent/installroot/lock.go b/apps/daemon/internal/agent/installroot/lock.go similarity index 100% rename from apps/parsar-daemon/internal/agent/installroot/lock.go rename to apps/daemon/internal/agent/installroot/lock.go diff --git a/apps/parsar-daemon/internal/agent/installroot/lock_test.go b/apps/daemon/internal/agent/installroot/lock_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/installroot/lock_test.go rename to apps/daemon/internal/agent/installroot/lock_test.go diff --git a/apps/daemon/internal/agent/installroot/probe.go b/apps/daemon/internal/agent/installroot/probe.go new file mode 100644 index 000000000..b185c31bc --- /dev/null +++ b/apps/daemon/internal/agent/installroot/probe.go @@ -0,0 +1,35 @@ +package installroot + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "io" + "strings" + "time" +) + +// Native diagnostics are deliberately discarded: dependencies may echo their +// environment. Readiness is separate from model credentials and a live Turn. +func Probe(parent context.Context, binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(parent, 25*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return "", errors.New("native component failed to start") + } + defer p.Cancel() + done := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) + if err != nil || len(raw) > 64*1024 { + p.Cancel() + } + _, _ = io.Copy(io.Discard, p.Stdout) + <-done + waitErr := p.Wait() + if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { + return "", errors.New("native component compatibility check failed") + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/apps/daemon/internal/agent/mcode/connection.go b/apps/daemon/internal/agent/mcode/connection.go new file mode 100644 index 000000000..bd6be0c66 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/connection.go @@ -0,0 +1,146 @@ +package mcode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" +) + +// connection is the process and ACP owner. It keeps draining while no Turn owns +// output; RPC identities remain unique across every Turn on this connection. +type connection struct { + process *clirunner.Process + exited chan struct{} + exitErr error + writeMu sync.Mutex + transportMu sync.Mutex + nextID int + responses map[string]chan rpcFrame + current *Session +} + +func (c *connection) read() { + defer close(c.exited) + stderrDone := make(chan struct{}) + go func() { defer close(stderrDone); _, _ = io.Copy(io.Discard, c.process.Stderr) }() + scanner := bufio.NewScanner(c.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) + var readErr error + for scanner.Scan() { + var frame rpcFrame + if json.Unmarshal(scanner.Bytes(), &frame) != nil { + readErr = fmt.Errorf("mcode: malformed ACP response") + c.process.Cancel() + break + } + c.transportMu.Lock() + response, owner := c.responses[string(frame.ID)], c.current + c.transportMu.Unlock() + if frame.Method == "" && response != nil { + select { + case response <- frame: + default: + } + continue + } + if owner == nil { + // A late request has no Turn authority. Never hold it for a successor. + if frame.Method != "" && len(frame.ID) > 0 { + if err := c.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32601, Message: "No active Turn"}}); err != nil { + readErr = err + c.process.Cancel() + break + } + } + continue + } + select { + case owner.frames <- frame: + case <-owner.finished: + case <-c.process.Context().Done(): + } + } + if scanner.Err() != nil { + readErr = fmt.Errorf("mcode: ACP stream read failed") + c.process.Cancel() + } + waitErr := c.process.Wait() + <-stderrDone + if readErr != nil { + c.exitErr = readErr + } else { + c.exitErr = waitErr + } +} + +func (c *connection) write(frame rpcFrame) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return json.NewEncoder(c.process.Stdin).Encode(frame) +} + +func (c *connection) reserveResponse() (string, chan rpcFrame) { + c.transportMu.Lock() + defer c.transportMu.Unlock() + c.nextID++ + id := strconv.Itoa(c.nextID) + reply := make(chan rpcFrame, 1) + c.responses[id] = reply + return id, reply +} + +func (c *connection) removeResponse(id string) { + c.transportMu.Lock() + delete(c.responses, id) + c.transportMu.Unlock() +} + +func (c *connection) setCurrent(s *Session) { + c.transportMu.Lock() + c.current = s + c.transportMu.Unlock() +} + +// An ordered ACP control response fences notifications left in the pipe by the +// preceding prompt. No Turn is attached while this barrier drains them. +func (c *connection) barrier(ctx context.Context, session, model string) error { + id, reply := c.reserveResponse() + defer c.removeResponse(id) + raw, _ := json.Marshal(map[string]string{"sessionId": session, "configId": "model", "value": model}) + err := c.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "session/set_config_option", Params: raw}) + if err != nil { + return err + } + select { + case frame := <-reply: + if frame.Error != nil { + return fmt.Errorf("mcode: native readiness barrier failed") + } + return nil + case <-c.exited: + return fmt.Errorf("mcode: native process exited") + case <-ctx.Done(): + return ctx.Err() + } +} + +// Drain the deadline callback before releasing the caller's Turn/start guard; +// an old writer deadline must never kill a subsequent owner. +func (c *connection) writeContext(ctx context.Context, frame rpcFrame) error { + callbackDone := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(callbackDone) + c.process.Cancel() + }) + err := c.write(frame) + if !stop() { + <-callbackDone + } + return err +} diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go new file mode 100644 index 000000000..d2ed2e6d4 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -0,0 +1,27 @@ +package mcode + +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.Steerer = (*Session)(nil) + _ agent.FunctionResultSubmitter = (*Session)(nil) + _ agent.PermissionResponder = (*Session)(nil) + _ agent.UserChoiceResponder = (*Session)(nil) + _ agent.WorkspaceReader = (*Session)(nil) + _ agent.WorkspaceDirectoryLister = (*Session)(nil) + _ agent.WorkspaceWriter = (*Session)(nil) + _ agent.Prepared = (*prepared)(nil) + _ agent.PreparedCancellation = (*prepared)(nil) + _ agent.WorkspaceReader = (*executor)(nil) + _ agent.WorkspaceDirectoryLister = (*executor)(nil) + _ agent.WorkspaceWriter = (*executor)(nil) + _ agent.WorkspaceReader = (*prepared)(nil) + _ agent.WorkspaceDirectoryLister = (*prepared)(nil) + _ agent.WorkspaceWriter = (*prepared)(nil) +) diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go similarity index 90% rename from apps/parsar-daemon/internal/agent/mcode/environment_mcp.go rename to apps/daemon/internal/agent/mcode/environment_mcp.go index 78a91d3c5..c675465c8 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -4,10 +4,10 @@ import ( "fmt" "net/url" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go similarity index 98% rename from apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go rename to apps/daemon/internal/agent/mcode/environment_mcp_test.go index 4bc09f370..efcd5b5e7 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func environmentMCPFixture() proto.EnvironmentMCP { diff --git a/apps/parsar-daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go similarity index 98% rename from apps/parsar-daemon/internal/agent/mcode/events.go rename to apps/daemon/internal/agent/mcode/events.go index 98651d49c..0b04ce6dd 100644 --- a/apps/parsar-daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -6,7 +6,7 @@ import ( "fmt" "net/url" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/execution.go rename to apps/daemon/internal/agent/mcode/execution.go index 4d2987310..6ad299a23 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // SupportsExecution is an operator opt-in, separate from ordinary product availability. diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/execution_test.go rename to apps/daemon/internal/agent/mcode/execution_test.go index 083897f94..d26b5e5e5 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func executionRequest(t *testing.T) proto.PromptRequestPayload { diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go new file mode 100644 index 000000000..69cf28357 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -0,0 +1,176 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +var errTurnCancelled = errors.New("mcode: Turn cancelled before submission") + +type executor struct { + mu sync.Mutex + connection *connection + req proto.PromptRequestPayload + opts launchOptions + nativeSession, model string + active *Session + starting, closed, invalid bool +} + +var _ agent.Executor = (*executor)(nil) +var _ agent.Turn = (*Session)(nil) + +// NewExecutorFactory fixes the deployment workspace once; nil selects none. +func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { + var frozen *WorkspaceConfig + if config != nil { + value := *config + value.AllowedDomains = append([]string(nil), config.AllowedDomains...) + frozen = &value + } + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") + } + var err error + var opts launchOptions + binary := defaultBinary() + if frozen == nil { + opts, err = prepareOptions(ctx, req) + } else { + binary = frozen.Binary + opts, err = prepareWorkspaceOptions(ctx, *frozen, req) + } + if err != nil { + return nil, err + } + resource, err := newExecutor(ctx, req, opts, binary) + if resource == nil { + return nil, err + } + return resource, err + } +} + +func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { + bootstrap, err := launch(ctx, req, opts, binary, nil) + if err != nil { + return nil, err + } + e := &executor{connection: bootstrap.connection, req: req, opts: opts} + err = bootstrap.prepareNative() + e.connection.setCurrent(nil) + close(bootstrap.finished) + if err == nil { + err = ctx.Err() + } + if err != nil { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if closeErr := e.Close(cleanup); closeErr != nil { + return e, err + } + return nil, err + } + e.nativeSession, e.model = bootstrap.sessionID, bootstrap.nativeModel + return e, nil +} + +func (e *executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(runID) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { + return nil, fmt.Errorf("mcode: Turn requires live context, identity, input and output") + } + text, err := input.TextOnly() + if err != nil { + return nil, err + } + e.mu.Lock() + if e.closed || e.invalid || e.starting || e.active != nil { + e.mu.Unlock() + return nil, fmt.Errorf("mcode: Executor is unavailable") + } + e.starting = true + e.mu.Unlock() + + // No model input is sent and no output channel retained until this fence. + barrier, cancel := context.WithTimeout(ctx, 60*time.Second) + err = e.connection.barrier(barrier, e.nativeSession, e.model) + cancel() + e.mu.Lock() + defer e.mu.Unlock() + e.starting = false + if err != nil || e.closed || ctx.Err() != nil { + if err != nil { + e.invalid = true + return nil, err + } + return nil, fmt.Errorf("mcode: Executor closed before input") + } + select { + case <-e.connection.exited: + e.invalid = true + return nil, fmt.Errorf("mcode: native process exited") + default: + } + req := e.req + req.RunID, req.Input = runID, proto.TextInput(text) + s := newTurnSession(e.connection.process.Context(), req, e.opts, e.connection, out) + s.executor, s.sessionID, s.nativeModel = e, e.nativeSession, e.model + s.settled, s.inputDone = make(chan struct{}), make(chan struct{}) + s.outputContext, s.outputCancel = context.WithCancel(context.Background()) + e.active = s + e.connection.setCurrent(s) + go s.runExecutorTurn() + go func() { + select { + case <-ctx.Done(): + cancellation, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + _ = s.Cancel(cancellation) + case <-s.settled: + } + }() + return s, nil +} + +// Close keeps the native owner reachable until process, pipes and active Turn +// settlement all finish. Repeating it waits on those same owners. +func (e *executor) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + e.mu.Lock() + e.closed = true + current := e.active + if current != nil { + current.outputCancel() + } + e.connection.process.Cancel() + e.mu.Unlock() + select { + case <-e.connection.exited: + case <-ctx.Done(): + return ctx.Err() + } + if current != nil { + select { + case <-current.settled: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go rename to apps/daemon/internal/agent/mcode/executor_backpressure_test.go index f2f78c6df..4651d46db 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go +++ b/apps/daemon/internal/agent/mcode/executor_backpressure_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestExecutorBlockedPromptWriteCanBeCancelledOrClosed(t *testing.T) { diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go new file mode 100644 index 000000000..e9dd4d9a3 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -0,0 +1,176 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls and uses an isolated native home. +// Provider options are read from a private file and never included in failures. +func TestNativeMCodeExecutorReuse(t *testing.T) { + binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || options == "" { + t.Skip("native executable and private provider options required") + } + ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) + defer cancel() + if version, err := CheckCLIAvailable(ctx, binary); err != nil || version != SupportedVersion { + t.Fatal("pinned native version verification failed") + } + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + req := executionRequest(t) + req.ReleaseOnCompletion = false + req.RunID, req.Input, req.ConversationID = "", nil, "" + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + value, err := NewExecutorFactory(nil)(ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed") + } + e := value.(*executor) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + if e.Close(cleanup) != nil { + t.Error("native owner cleanup failed") + } + }() + pid, nativeID := e.connection.process.Cmd.Process.Pid, e.nativeSession + assertOwner := func() { + t.Helper() + if e.connection.process.Cmd.Process.Pid != pid || e.nativeSession != nativeID { + t.Fatal("native owner changed") + } + select { + case <-e.connection.exited: + t.Fatal("native process exited") + default: + } + } + run := func(id, prompt string) agent.Turn { + t.Helper() + out := make(chan proto.Envelope, 256) + turn, startErr := e.StartTurn(ctx, id, proto.TextInput(prompt), out) + if startErr != nil || turn == nil { + t.Fatal("native Turn admission failed") + } + content := "" + doneCount := 0 + for event := range out { + if event.ID != id { + t.Fatal("event crossed Turn boundary") + } + switch event.Type { + case proto.TypeError: + t.Fatal("native Turn reported an error") + case proto.TypeDone: + var done proto.DonePayload + if json.Unmarshal(event.Payload, &done) != nil { + t.Fatal("invalid completion") + } + content = done.Content + doneCount++ + } + } + settlement, settleErr := turn.AwaitSettlement(ctx) + if settleErr != nil || !settlement.Reusable { + t.Fatal("normal Turn did not establish reusable settlement") + } + if doneCount != 1 || !strings.Contains(content, "ORCHID-72-BLUE") { + t.Fatal("native conversation did not retain the private test marker") + } + assertOwner() + t.Logf("Verified marker recall and retained native owner for %s", id) + return turn + } + first := run("first", "Remember the exact marker ORCHID-72-BLUE for this conversation. Reply with only that marker.") + run("second", "What exact marker did I ask you to remember? Reply with only that marker.") + out := make(chan proto.Envelope, 256) + interrupted, err := e.StartTurn(ctx, "cancelled", proto.TextInput("Produce 2000 numbered lines now. Each line must contain a different sentence about rain. Start immediately at line 1 and continue without stopping or summarizing."), out) + if err != nil || interrupted == nil { + t.Fatal("cancellation Turn admission failed") + } + streaming := false + for !streaming { + select { + case event, ok := <-out: + if !ok || event.Type == proto.TypeDone || event.Type == proto.TypeError { + t.Fatal("Turn ended before live cancellation could be tested") + } + if event.Type == proto.TypeDelta { + streaming = true + } + case <-ctx.Done(): + t.Fatal("native stream did not begin before deadline") + } + } + select { + case <-interrupted.(*Session).finished: + t.Fatal("cancellation was not in flight") + default: + } + staleCtx, staleStop := context.WithTimeout(ctx, 5*time.Second) + if first.Cancel(staleCtx) != nil { + staleStop() + t.Fatal("stale cancellation failed") + } + staleStop() + interrupted.(*Session).mu.Lock() + wasCancelled := interrupted.(*Session).cancelled + interrupted.(*Session).mu.Unlock() + if wasCancelled { + t.Fatal("stale cancellation targeted current Turn") + } + stopCtx, stop := context.WithTimeout(ctx, 30*time.Second) + cancelErr := interrupted.Cancel(stopCtx) + settlement, settleErr := interrupted.AwaitSettlement(stopCtx) + stop() + if settleErr != nil || cancelErr != nil { + t.Fatal("native cancellation did not settle", cancelErr, settleErr) + } + if !settlement.Reusable { + t.Log("Native cancellation cannot establish reusable settlement; successor must use recovery") + successor := make(chan proto.Envelope, 1) + next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TextInput("must not execute"), successor) + if next != nil || nextErr == nil { + t.Fatal("unsettled native owner accepted a successor") + } + close(successor) + cleanup, cleanupStop := context.WithTimeout(ctx, 15*time.Second) + if e.Close(cleanup) != nil { + cleanupStop() + t.Fatal("invalid native owner did not close") + } + cleanupStop() + req.AgentSessionID = nativeID + recovered, recoverErr := NewExecutorFactory(nil)(ctx, req) + if recoverErr != nil || recovered == nil { + t.Fatal("exact native history recovery failed") + } + e = recovered.(*executor) + if e.nativeSession != nativeID || e.connection.process.Cmd.Process.Pid == pid { + t.Fatal("recovery did not replace the process while retaining native history") + } + pid = e.connection.process.Cmd.Process.Pid + run("recovery", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") + t.Log("Verified exact history continuation in a replacement native owner after nonreusable cancellation") + return + } + if cancelErr != nil { + t.Fatal("settled cancellation reported an error") + } + assertOwner() + run("continuation", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") + t.Log("Verified native process and session reuse across two normal Turns, active cancellation, and continuation") +} diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go new file mode 100644 index 000000000..eeac6a0ce --- /dev/null +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -0,0 +1,274 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { + t.Helper() + config, req, record := workspaceFixture(t) + req.ReleaseOnCompletion = false + script, err := os.ReadFile(config.Binary) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(config.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER="+scenario, 1)), 0700); err != nil { + t.Fatal(err) + } + var factory agent.ExecutorFactory + if workspace { + factory = NewExecutorFactory(&config) + } else { + req = executionRequest(t) + req.ReleaseOnCompletion = false + req.RunID, req.Input, req.ConversationID = "", nil, "" + t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) + factory = NewExecutorFactory(nil) + } + value, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + e := value.(*executor) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(cleanup); err != nil { + t.Error(err) + } + }) + return e, record +} + +func runExecutorFixtureTurn(t *testing.T, e *executor, id, text string) (*Session, []proto.Envelope) { + t.Helper() + out := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput(text), out) + if err != nil { + t.Fatal(err) + } + var events []proto.Envelope + for event := range out { + events = append(events, event) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err != nil || !settlement.Reusable { + t.Fatalf("settlement = %+v, %v", settlement, err) + } + return turn.(*Session), events +} + +func TestExecutorReusesNativeOwnerWithFreshTurnsAndIdleDrain(t *testing.T) { + for _, workspace := range []bool{false, true} { + t.Run(map[bool]string{false: "none", true: "workspace"}[workspace], func(t *testing.T) { + e, record := executorFixture(t, "executor-reuse", workspace) + pid := e.connection.process.Cmd.Process.Pid + first, _ := runExecutorFixtureTurn(t, e, "first", "one") + second, events := runExecutorFixtureTurn(t, e, "second", "two") + if first == second || e.connection.process.Cmd.Process.Pid != pid { + t.Fatal("Turn reused mutable state or rebuilt the process") + } + text, tools, done := "", 0, 0 + for _, event := range events { + if event.ID != "second" { + t.Fatal("event escaped its Turn", event.ID) + } + switch event.Type { + case proto.TypeError: + t.Fatalf("execution failed: %s", event.Payload) + case proto.TypeDelta: + var delta proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &delta) + text += delta.Delta + if delta.Sequence != 1 { + t.Fatal("sequence carried over from old Turn") + } + case proto.TypeToolCall: + tools++ + case proto.TypeDone: + done++ + } + } + if text != "two" || tools != 2 || done != 1 { + t.Fatalf("cross-Turn state: text=%q tools=%d done=%d", text, tools, done) + } + raw, _ := os.ReadFile(record) + if strings.Count(string(raw), "initialize\n") != 1 || strings.Count(string(raw), "session/new\n") != 1 || strings.Count(string(raw), "session/prompt\n") != 2 { + t.Fatalf("native owner was recreated: %s", raw) + } + select { + case <-e.connection.exited: + t.Fatal("completion killed native owner") + default: + } + }) + } +} + +func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing.T) { + for _, disabled := range []bool{true, false} { + t.Run(map[bool]string{true: "single-agent", false: "subagents"}[disabled], func(t *testing.T) { + e, record := executorFixture(t, "executor-cancel", true) + first, _ := runExecutorFixtureTurn(t, e, "first", "one") + e.req.DisableSubagents = disabled + if !disabled { + // A terminal native history record still cannot prove Bash cleanup. + dir := t.TempDir() + node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + body := "#!/bin/sh\nprintf '%s\\n' '{\"version\":1,\"complete\":true,\"rootSessionId\":\"native-1\",\"sessions\":[{\"id\":\"native-1\",\"turns\":[{\"id\":\"root-turn\",\"status\":\"aborted\"}]}]}'\n" + for name, data := range map[string]string{node: body, bridge: "", filepath.Join(dir, "subagent-snapshot.mjs"): ""} { + if err := os.WriteFile(name, []byte(data), 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_RUNTIME_MCODE_NODE", node) + t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + } + out := make(chan proto.Envelope, 32) + second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) + if err != nil { + t.Fatal(err) + } + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatal(event.Type) + } + case <-time.After(3 * time.Second): + t.Fatal("second Turn did not start") + } + if err = first.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/cancel") { + t.Fatal("late cancellation targeted the new Turn") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err = second.Cancel(ctx); err != nil { + t.Fatal("stopped nonreusable owner reported cancellation failure", err) + } + settlement, err := second.AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal(settlement, err) + } + select { + case <-e.connection.exited: + default: + t.Fatal("nonreusable settlement preceded native process exit") + } + if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("cancel lost settled outcome", got) + } + for range out { + } + thirdOut := make(chan proto.Envelope, 1) + third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) + if third != nil || err == nil { + t.Fatal("unproven native owner admitted a successor") + } + close(thirdOut) + raw, _ = os.ReadFile(record) + if strings.Count(string(raw), "session/cancel\n") != 1 || strings.Count(string(raw), "initialize\n") != 1 { + t.Fatalf("cancellation replaced owner: %s", raw) + } + }) + } +} + +func TestExecutorStartFailureOwnership(t *testing.T) { + t.Run("validation", func(t *testing.T) { + e, record := executorFixture(t, "executor-reuse", true) + out := make(chan proto.Envelope, 1) + turn, err := e.StartTurn(t.Context(), "", proto.TextInput("invalid"), out) + if err == nil || turn != nil { + t.Fatal("invalid Start acquired output") + } + close(out) + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/prompt") { + t.Fatal("invalid input was sent") + } + runExecutorFixtureTurn(t, e, "healthy", "valid") + }) + t.Run("before input", func(t *testing.T) { + e, record := executorFixture(t, "executor-preexit", true) + out := make(chan proto.Envelope, 1) + turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + if err == nil || turn != nil { + t.Fatal("failed readiness retained caller output") + } + close(out) + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/prompt") { + t.Fatal("pre-input failure sent input") + } + }) + t.Run("after submission", func(t *testing.T) { + e, record := executorFixture(t, "executor-exit", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + if err != nil || turn == nil { + t.Fatal("submitted Turn lost ownership", err) + } + for range out { + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err == nil || settlement.Reusable { + t.Fatal("native exit lost its settlement error", settlement, err) + } + raw, _ := os.ReadFile(record) + if strings.Count(string(raw), "session/prompt\n") != 1 { + t.Fatal("uncertain input was replayed") + } + rejected := make(chan proto.Envelope) + again, err := e.StartTurn(t.Context(), "again", proto.TextInput("again"), rejected) + if again != nil || err == nil { + t.Fatal("invalid owner accepted another Turn") + } + close(rejected) + }) +} + +func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { + e, _ := executorFixture(t, "executor-reuse", true) + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + _ = e.Close(cancelled) + ctx, stop := context.WithTimeout(t.Context(), 5*time.Second) + defer stop() + if err := e.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-e.connection.exited: + default: + t.Fatal("Close lost native cleanup ownership") + } +} + +func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { + config, req, _ := workspaceFixture(t) + req.ReleaseOnCompletion = false + if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + value, err := NewExecutorFactory(&config)(t.Context(), req) + if err == nil || value != nil { + t.Fatalf("settled preparation failure returned owner: nil=%t error=%v", value == nil, err) + } +} diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go new file mode 100644 index 000000000..3bf537374 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -0,0 +1,151 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (s *Session) runExecutorTurn() { + err := s.captureSubagentBaseline() + if err == nil { + err = s.executePrompt() + } + if errors.Is(err, errTurnCancelled) { + err = nil + } + s.mu.Lock() + s.closing, s.steeringReady = true, false + close(s.inputDone) + s.mu.Unlock() + // Written steering requests retain their original receipt owner even after + // prompt completion. No successor starts until all callers have settled. + s.operations.Wait() + if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady { + childErr := s.settleSubagents() + s.mu.Lock() + s.subagentSettlementError = childErr + s.mu.Unlock() + if childErr != nil { + err = childErr + } + } + reusable := err == nil && s.process.Context().Err() == nil + if len(s.tools) > 0 { + reusable = false + } + s.finishEnvironmentMCP() + s.mu.Lock() + // ACP and native history cancellation do not prove detached tool cleanup. + // Retire the owner and settle its workers before acknowledging cancellation. + if s.cancelled || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { + reusable = false + } + if s.inputUncertain && err == nil { + err = fmt.Errorf("mcode: native input outcome is unknown") + } + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} + s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} + outcome := s.outcome + s.permissions, s.questions = map[string]pendingPermission{}, map[string]pendingQuestion{} + s.mu.Unlock() + if !reusable { + // Unknown quiescence invalidates this owner. A successful settlement + // still proves its native process group and pipes have stopped. + s.process.Cancel() + <-s.exited + } + if err != nil { + s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) + } + s.emit(proto.TypeDone, outcome) + close(s.out) + close(s.finished) + s.executor.mu.Lock() + s.connection.setCurrent(nil) + if !reusable { + s.executor.invalid = true + } + s.executor.active = nil + s.settlement = agent.TurnSettlement{Reusable: reusable} + s.settlementErr = err + if !reusable { + s.settlement.Reason = "native Turn did not establish reusable settlement" + } + close(s.settled) + s.executor.mu.Unlock() + s.outputCancel() +} + +func (s *Session) captureSubagentBaseline() error { + if !s.req.StrictResume || s.req.DisableSubagents { + return nil + } + snapshot, err := s.readSubagents(s.ctx) + s.subagentHistoryReady = err == nil + s.previousNativeTurns = map[string]bool{} + for _, session := range snapshot.Sessions { + if session.ID == s.sessionID { + for _, turn := range session.Turns { + s.previousNativeTurns[turn.ID] = true + } + } + } + return err +} + +func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + if s.settled == nil { + return agent.TurnSettlement{}, fmt.Errorf("mcode: Turn has no Executor owner") + } + select { + case <-s.settled: + return s.settlement, s.settlementErr + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func (s *Session) cancelTurn(ctx context.Context) error { + e := s.executor + e.mu.Lock() + if e.active != s { + e.mu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err + } + s.mu.Lock() + first := !s.cancelled && !s.closing + s.cancelled = true + if first { + s.operations.Add(1) + } + s.mu.Unlock() + // Cancellation releases event backpressure but does not cancel native owner + // context. After native settlement, cancellation retires and drains the owner. + s.outputCancel() + e.mu.Unlock() + var err error + if first { + raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) + err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) + } + if first { + if err == nil && s.req.StrictResume && !s.req.DisableSubagents { + err = s.stopSubagents(ctx) + } + if err != nil { + s.markInputUncertain() + } + s.operations.Done() + } + if err != nil { + return err + } + _, err = s.AwaitSettlement(ctx) + return err +} diff --git a/apps/daemon/internal/agent/mcode/harness_config_test.go b/apps/daemon/internal/agent/mcode/harness_config_test.go new file mode 100644 index 000000000..eaf4f2c1a --- /dev/null +++ b/apps/daemon/internal/agent/mcode/harness_config_test.go @@ -0,0 +1,19 @@ +package mcode + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "testing" +) + +func TestNativeConfigDoesNotPretendToSupportParameters(t *testing.T) { + req := testRequest(t) + req.AgentOptions["harness_config"] = map[string]any{} + if _, err := prepareOptions(context.Background(), req); err != nil { + t.Fatal(err) + } + req.AgentOptions["harness_config"] = map[string]any{"temperature": 0.5} + if _, err := prepareOptions(context.Background(), req); err != harnessconfig.ErrHarnessConfig { + t.Fatalf("unsupported parameter accepted: %v", err) + } +} diff --git a/apps/daemon/internal/agent/mcode/installation.go b/apps/daemon/internal/agent/mcode/installation.go new file mode 100644 index 000000000..fe75d9258 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/installation.go @@ -0,0 +1,28 @@ +package mcode + +import ( + "context" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + return agent.Installation{AgentKind: "mcode", Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "bridge.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} + }, + 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) + if err != nil || got != SupportedVersion { + return fmt.Errorf("MiniMax installation is incompatible") + } + raw, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "check.mjs")}, env, dir) + if err != nil || ValidateWorkspaceReadiness([]byte(raw)) != nil { + return fmt.Errorf("MiniMax companion is unavailable or incompatible; use the current native distribution and verify Bash") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go b/apps/daemon/internal/agent/mcode/mcp_observations.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/mcp_observations.go rename to apps/daemon/internal/agent/mcode/mcp_observations.go index f017a0b4e..364d8995b 100644 --- a/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations.go @@ -8,8 +8,8 @@ import ( "slices" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type mcpToolIdentity struct{ server, tool string } diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go rename to apps/daemon/internal/agent/mcode/mcp_observations_test.go index 6c0435e32..1c790f731 100644 --- a/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { diff --git a/apps/parsar-daemon/internal/agent/mcode/model_provider.go b/apps/daemon/internal/agent/mcode/model_provider.go similarity index 85% rename from apps/parsar-daemon/internal/agent/mcode/model_provider.go rename to apps/daemon/internal/agent/mcode/model_provider.go index ba1b9e95a..b25385f01 100644 --- a/apps/parsar-daemon/internal/agent/mcode/model_provider.go +++ b/apps/daemon/internal/agent/mcode/model_provider.go @@ -3,8 +3,8 @@ package mcode import ( "fmt" - harnessconfiguration "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/mcode" - "github.com/MiniMax-AI-Dev/parsar/internal/modelprovider" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) func modelProviderConfig(raw any, model string) (map[string]any, error) { diff --git a/apps/parsar-daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/model_provider_test.go rename to apps/daemon/internal/agent/mcode/model_provider_test.go diff --git a/apps/parsar-daemon/internal/agent/mcode/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/mcode/native_history_test.go rename to apps/daemon/internal/agent/mcode/native_history_test.go index f045bf581..0106570cc 100644 --- a/apps/parsar-daemon/internal/agent/mcode/native_history_test.go +++ b/apps/daemon/internal/agent/mcode/native_history_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // A successful public real-model run supplies an actual foreign native ID. diff --git a/apps/parsar-daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go similarity index 99% rename from apps/parsar-daemon/internal/agent/mcode/native_test.go rename to apps/daemon/internal/agent/mcode/native_test.go index 53e8f9251..bb61fdeb3 100644 --- a/apps/parsar-daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Opt in with the installed native CLI; the default test gate uses protocol fixtures. diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go new file mode 100644 index 000000000..fef178065 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/options.go @@ -0,0 +1,250 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +type launchOptions struct { + Dir, DataDir, Model string + Env []string + MCP []map[string]any +} + +func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { + return prepareOptionsWithSkills(ctx, req, true) +} + +func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { + var result launchOptions + if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { + return result, err + } + if req.StrictResume { + if err := validateExecutionRequest(req); err != nil { + return result, err + } + } + if req.Input.HasImages() { + return result, fmt.Errorf("mcode: ACP does not support attachments") + } + root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return result, err + } + result.DataDir = filepath.Dir(root) + result.Dir, err = workDir(req.WorkDir, filepath.Join(result.DataDir, "workspace")) + if err != nil { + return result, err + } + if err := os.MkdirAll(result.DataDir, 0o700); err != nil { + return result, err + } + if req.WorkDir == "" { + if err := os.MkdirAll(result.Dir, 0o700); err != nil { + return result, err + } + } + if managedSkills { + installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) + if err != nil { + return result, err + } + if len(installed.Warnings) > 0 { + return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") + } + } + opts := req.AgentOptions + prompt := optionString(opts, "system_prompt") + if override := optionString(opts, "override_system_prompt"); override != "" { + prompt = override + } + if len(prompt) > 32*1024 { + return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") + } + if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { + return result, err + } + config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} + result.Model = optionString(opts, "model") + if result.Model == "" { + return result, fmt.Errorf("mcode: model is required") + } + provider, err := modelProviderConfig(opts["model_provider"], result.Model) + if err != nil { + return result, err + } + config["custom_provider"] = map[string]any{"oac": provider} + if req.StrictResume { + configureTextExecution(config) + if !req.DisableSubagents { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, + "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, + }} + } + } + mode := optionString(opts, "mode") + if mode == "" { + mode = "auto" + } + if mode != "auto" && mode != "default" && mode != "bypassPermissions" { + return result, fmt.Errorf("mcode: unsupported permission mode") + } + config["permissionMode"] = mode + data, err := json.Marshal(config) + if err != nil { + return result, err + } + if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { + return result, err + } + result.Env = append([]string{}, os.Environ()...) + if req.StrictResume { + result.Env = append(executionEnvironment(), "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") + if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { + return result, err + } + if !req.DisableSubagents { + result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + } else { + result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") + } + } + if raw := opts["env"]; raw != nil && !req.StrictResume { + env, ok := raw.(map[string]any) + if !ok { + return result, fmt.Errorf("mcode: env must be an object") + } + for key, rawValue := range env { + value, ok := rawValue.(string) + if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { + return result, fmt.Errorf("mcode: invalid environment entry") + } + result.Env = append(result.Env, key+"="+value) + } + } + // The adapter owns the native state location, including after cold resume. + result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) + if req.StrictResume { + result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) + } + result.MCP, err = mcpServers(opts["mcp_servers"]) + return result, err +} + +func workDir(raw, fallback string) (string, error) { + if raw == "" { + return fallback, nil + } + if strings.HasPrefix(raw, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + raw = filepath.Join(home, raw[2:]) + } + if !filepath.IsAbs(raw) { + return "", fmt.Errorf("mcode: working directory must be absolute or start with ~/") + } + return filepath.Clean(raw), nil +} + +func optionString(options map[string]any, key string) string { + value, _ := options[key].(string) + return value +} + +func mcpServers(raw any) ([]map[string]any, error) { + result := []map[string]any{} + if raw == nil { + return result, nil + } + servers, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: mcp_servers must be an object") + } + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + entry, ok := servers[name].(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: invalid MCP server %q", name) + } + server := map[string]any{"name": name} + if url := optionString(entry, "url"); url != "" { + kind := optionString(entry, "type") + if kind == "" { + kind = "http" + } + if kind != "http" && kind != "sse" { + return nil, fmt.Errorf("mcode: unsupported MCP transport %q", kind) + } + server["type"] = kind + server["url"] = url + headers, err := namedValues(entry["headers"]) + if err != nil { + return nil, err + } + server["headers"] = headers + } else { + command := optionString(entry, "command") + if command == "" { + return nil, fmt.Errorf("mcode: MCP server %q needs command or URL", name) + } + server["command"] = command + args := entry["args"] + if args == nil { + args = []string{} + } + server["args"] = args + env, err := namedValues(entry["env"]) + if err != nil { + return nil, err + } + server["env"] = env + } + result = append(result, server) + } + return result, nil +} + +func namedValues(raw any) ([]map[string]string, error) { + result := []map[string]string{} + if raw == nil { + return result, nil + } + values, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("mcode: MCP headers/env must be an object") + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value, ok := values[key].(string) + if !ok { + return nil, fmt.Errorf("mcode: MCP headers/env values must be strings") + } + result = append(result, map[string]string{"name": key, "value": value}) + } + return result, nil +} diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go new file mode 100644 index 000000000..9e2db80bf --- /dev/null +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -0,0 +1,119 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestOptionsRefreshManagedState(t *testing.T) { + req := testRequest(t) + req.AgentOptions["env"] = map[string]any{"MINIMAX_DATA_DIR": "/wrong", "FIXTURE": "yes"} + opts, err := prepareOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(opts.Dir, os.Getenv("OAC_RUNTIME_HOME")+string(os.PathSeparator)) { + t.Fatalf("workdir escaped managed state: %s", opts.Dir) + } + if opts.Env[len(opts.Env)-1] != "MINIMAX_DATA_DIR="+opts.DataDir { + t.Fatal("state override did not win") + } + req.AgentOptions["system_prompt"] = "" + req.AgentOptions["mode"] = "default" + req.AgentSessionID = "native-1" + refreshed, err := prepareOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if refreshed.DataDir != opts.DataDir { + t.Fatal("resume moved native state") + } + content, err := os.ReadFile(filepath.Join(opts.DataDir, "AGENTS.md")) + if err != nil || len(content) != 0 { + t.Fatal("removed instructions retained on resume") + } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if json.Unmarshal(data, &cfg) != nil { + t.Fatal("invalid config") + } + if cfg["permissionMode"] != "default" { + t.Fatal("requested native permission mode was not refreshed") + } + if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { + t.Fatal("external discovery enabled") + } + info, _ := os.Stat(filepath.Join(opts.DataDir, "config.yaml")) + if info.Mode().Perm() != 0600 { + t.Fatal("native credentials file is not private") + } +} + +func TestOptionsRejectDroppedContext(t *testing.T) { + tests := []struct { + name string + edit func(*proto.PromptRequestPayload) + }{ + {"relative workdir", func(r *proto.PromptRequestPayload) { r.WorkDir = "relative" }}, + {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, + {"attachment", func(r *proto.PromptRequestPayload) { + r.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} + }}, + {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, + {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, + {"invalid permission mode", func(r *proto.PromptRequestPayload) { r.AgentOptions["mode"] = "plan" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := testRequest(t) + tt.edit(&req) + if _, err := prepareOptions(context.Background(), req); err == nil { + t.Fatal("expected validation failure") + } + }) + } +} + +func TestMCPTransportConversion(t *testing.T) { + servers, err := mcpServers(map[string]any{ + "local": map[string]any{"command": "fixture", "args": []any{"--stdio"}, "env": map[string]any{"TOKEN": "test"}}, + "remote": map[string]any{"type": "http", "url": "https://mcp.example.test", "headers": map[string]any{"Authorization": "Bearer fixture"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(servers) != 2 || servers[0]["command"] != "fixture" || servers[1]["type"] != "http" { + t.Fatalf("servers=%v", servers) + } + if servers[0]["env"].([]map[string]string)[0]["name"] != "TOKEN" || servers[1]["headers"].([]map[string]string)[0]["value"] != "Bearer fixture" { + t.Fatal("MCP credentials lost") + } +} + +func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { + pending := pendingQuestion{Properties: map[string]formProperty{"text": {Type: "string"}, "choice": {Type: "string", Options: []formOption{{Value: "eu", Title: "Europe"}}}}, Required: []string{"choice"}} + if _, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{}); err == nil { + t.Fatal("required answer accepted empty") + } + decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "text", Answers: []string{"custom"}}, {QuestionID: "choice", Answers: []string{"Europe"}}}} + content, err := questionContent(pending, decision) + if err != nil { + t.Fatal(err) + } + if content["choice"] != "eu" || content["text"] != "custom" { + t.Fatalf("answers=%v", content) + } + decision.QuestionAnswers[1].Answers = []string{"unoffered"} + if _, err := questionContent(pending, decision); err == nil { + t.Fatal("unoffered choice accepted") + } +} diff --git a/apps/daemon/internal/agent/mcode/preparation.go b/apps/daemon/internal/agent/mcode/preparation.go new file mode 100644 index 000000000..1366cb5ba --- /dev/null +++ b/apps/daemon/internal/agent/mcode/preparation.go @@ -0,0 +1,87 @@ +package mcode + +import ( + "context" + "fmt" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Prepared retains its single admission API over the same native Executor. +// Runtime's Session lifecycle uses Executor directly. +type prepared struct { + mu sync.Mutex + executor *executor + session *Session + started, closed bool +} + +func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { + factory := NewExecutorFactory(&config) + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + value, err := factory(ctx, req) + if err != nil { + if value != nil { + return &prepared{executor: value.(*executor)}, err + } + return nil, err + } + e := value.(*executor) + return &prepared{executor: e, session: newTurnSession(ctx, req, e.opts, e.connection, nil)}, nil + } +} + +func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || p.started { + return nil, fmt.Errorf("mcode: preparation is no longer available") + } + turn, err := p.executor.StartTurn(ctx, runID, input, out) + if turn != nil { + p.started, p.session = true, turn.(*Session) + } + return turn, err +} + +func (p *prepared) Close() error { + p.mu.Lock() + started := p.started + if !started { + p.closed = true + } + p.mu.Unlock() + if started { + return nil + } + return p.executor.Close(context.Background()) +} + +func (p *prepared) Cancel(ctx context.Context) error { + p.mu.Lock() + p.closed = true + current, started := p.session, p.started + p.mu.Unlock() + if started { + if err := current.Cancel(ctx); err != nil { + // The single-use Prepared owns disposal as well as cancellation. + if closeErr := p.executor.Close(ctx); closeErr != nil { + return closeErr + } + return nil + } + } + return p.executor.Close(ctx) +} + +func (p *prepared) CancellationOutcome() proto.DonePayload { + p.mu.Lock() + s := p.session + p.mu.Unlock() + if s == nil { + return proto.DonePayload{} + } + return s.CancellationOutcome() +} diff --git a/apps/daemon/internal/agent/mcode/preparation_test.go b/apps/daemon/internal/agent/mcode/preparation_test.go new file mode 100644 index 000000000..a75861bf3 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/preparation_test.go @@ -0,0 +1,166 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { + t.Helper() + r := executionRequest(t) + r.RunID, r.Input, r.ConversationID = "", nil, "" + r.DisableExecutionEnvironment = false + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} + r.WorkDir = t.TempDir() + record := filepath.Join(t.TempDir(), "calls") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "native") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "enabled", Scratch: t.TempDir()}, r, record +} + +func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { + c, r, record := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + t.Cleanup(func() { _ = p.Cancel(context.Background()) }) + raw, err := os.ReadFile(record) + if err != nil || strings.Contains(string(raw), "session/prompt") { + t.Fatalf("preparation consumed input: %q %v", raw, err) + } + if p.session.opts.Dir != r.WorkDir || p.session.opts.DataDir == r.WorkDir { + t.Fatal("native cwd must use the workspace without moving Session state") + } + out := make(chan proto.Envelope) + var wg sync.WaitGroup + winners := make(chan bool, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := p.Start(ctx, "run", proto.TextInput("input"), out) + winners <- err == nil + }() + } + wg.Wait() + close(winners) + n := 0 + for winner := range winners { + if winner { + n++ + } + } + if n != 1 { + t.Fatalf("owners=%d", n) + } + if err := p.Close(); err != nil { + t.Fatal(err) + } + deltas, done := 0, 0 + for e := range out { + if e.Type == proto.TypeError { + t.Fatalf("execution: %s", e.Payload) + } + if e.Type == proto.TypeDelta { + deltas++ + } + if e.Type == proto.TypeDone { + done++ + select { + case <-p.session.exited: + t.Fatal("successful Turn disposed the reusable native owner") + default: + } + var d proto.DonePayload + _ = json.Unmarshal(e.Payload, &d) + if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("native identity lost") + } + } + } + if deltas != 100 || done != 1 { + t.Fatalf("deltas=%d done=%d", deltas, done) + } + raw, _ = os.ReadFile(record) + if strings.Count(string(raw), "session/prompt") != 1 { + t.Fatalf("inputs=%s", raw) + } +} + +func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { + c, r, _ := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + if err = resource.Close(); err != nil { + t.Fatal(err) + } + if _, err = resource.Start(ctx, "run", proto.TextInput("input"), make(chan proto.Envelope)); err == nil { + t.Fatal("released preparation started") + } + if err = resource.Close(); err != nil { + t.Fatal(err) + } +} + +func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { + for _, method := range []string{"close", "cancel"} { + t.Run(method, func(t *testing.T) { + c, r, record := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + // The fixture only implements preparation. Enable execution cancellation's + // child branch after initialization to verify unused owners never enter it. + p.session.req.DisableSubagents = false + ended := make(chan error, 1) + go func() { + if method == "close" { + ended <- p.Close() + } else { + ended <- p.Cancel(ctx) + } + }() + select { + case err := <-ended: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + p.session.process.Cancel() + t.Fatal("unused owner did not close") + } + raw, err := os.ReadFile(record) + if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { + t.Fatalf("unused preparation executed work: %q %v", raw, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/protocol.go b/apps/daemon/internal/agent/mcode/protocol.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/protocol.go rename to apps/daemon/internal/agent/mcode/protocol.go diff --git a/apps/parsar-daemon/internal/agent/mcode/questions.go b/apps/daemon/internal/agent/mcode/questions.go similarity index 98% rename from apps/parsar-daemon/internal/agent/mcode/questions.go rename to apps/daemon/internal/agent/mcode/questions.go index 6dcb99796..b61721d97 100644 --- a/apps/parsar-daemon/internal/agent/mcode/questions.go +++ b/apps/daemon/internal/agent/mcode/questions.go @@ -7,8 +7,8 @@ import ( "sort" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/mcode/questions_test.go b/apps/daemon/internal/agent/mcode/questions_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/questions_test.go rename to apps/daemon/internal/agent/mcode/questions_test.go index d6ba44a1b..c46562977 100644 --- a/apps/parsar-daemon/internal/agent/mcode/questions_test.go +++ b/apps/daemon/internal/agent/mcode/questions_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestQuestionnaireOtherUsesOneQuestion(t *testing.T) { diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go new file mode 100644 index 000000000..d93877cfa --- /dev/null +++ b/apps/daemon/internal/agent/mcode/session.go @@ -0,0 +1,409 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type Session struct { + ctx context.Context + req proto.PromptRequestPayload + opts launchOptions + *connection + executor *executor + settlement agent.TurnSettlement + settlementErr error + settled chan struct{} + inputDone chan struct{} + outputCancel context.CancelFunc + operations sync.WaitGroup + closing bool + cancelled bool + inputUncertain bool + out chan<- proto.Envelope + frames chan rpcFrame + finished chan struct{} + mu sync.Mutex + sessionID string + nativeModel string + outputContext context.Context + outcome proto.DonePayload + permissions map[string]pendingPermission + questions map[string]pendingQuestion + steeringReady bool + steeringTurn string + sequence uint64 + active bool + content strings.Builder + tools map[string]toolUpdate + completedTools map[string]bool + previousNativeTurns map[string]bool + subagentSettlementError error + rootCompletedAtMS *int64 + subagentHistoryReady bool +} + +var _ agent.Session = (*Session)(nil) + +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultBinary()) +} + +func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, binary string) (*Session, error) { + if ctx == nil { + ctx = context.Background() + } + if out == nil { + return nil, fmt.Errorf("mcode: output channel is required") + } + opts, err := prepareOptions(ctx, req) + if err != nil { + return nil, err + } + s, err := launch(ctx, req, opts, binary, out) + if err != nil { + return nil, err + } + go s.run() + return s, nil +} + +func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string, out chan<- proto.Envelope) (*Session, error) { + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) + if err != nil { + return nil, err + } + c := &connection{process: process, exited: make(chan struct{}), responses: map[string]chan rpcFrame{}} + s := newTurnSession(ctx, req, opts, c, out) + c.current = s + go c.read() + return s, nil +} + +func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} +} + +func (s *Session) run() { + defer func() { + if s.out != nil { + close(s.out) + } + }() + defer close(s.finished) + err := s.prepareNative() + if err == nil { + if s.req.StrictResume && !s.req.DisableSubagents { + var snapshot nativeSubagentSnapshot + snapshot, err = s.readSubagents(s.ctx) + s.subagentHistoryReady = err == nil + s.previousNativeTurns = map[string]bool{} + for _, session := range snapshot.Sessions { + if session.ID == s.sessionID { + for _, turn := range session.Turns { + s.previousNativeTurns[turn.ID] = true + } + } + } + } + } + if err == nil { + err = s.executePrompt() + } + if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { + observationErr := s.settleSubagents() + s.mu.Lock() + s.subagentSettlementError = observationErr + s.mu.Unlock() + if observationErr != nil { + err = observationErr + } + } + if err != nil { + s.process.Cancel() + <-s.exited + } + s.finishEnvironmentMCP() + if s.out == nil { + return + } + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() + if err != nil { + s.process.Cancel() + s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) + } + s.mu.Lock() + sessionID := s.sessionID + s.permissions = map[string]pendingPermission{} + s.questions = map[string]pendingQuestion{} + s.mu.Unlock() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} + if sessionID != "" { + metadata[proto.DoneMetaAgentSessionID] = sessionID + } + // ACP context usage is not per-turn token usage; do not record it as spend. + s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS}) +} + +func (s *Session) prepareNative() error { + var initialized struct { + ProtocolVersion int `json:"protocolVersion"` + Meta struct { + Subagents struct { + Version, MaxConcurrent int + WorkspaceTools string + } `json:"oac/subagents"` + } `json:"_meta"` + } + if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "oac", "version": "1"}, "clientCapabilities": map[string]any{"elicitation": map[string]any{"form": map[string]any{}}}}, &initialized, false); err != nil { + return err + } + if initialized.ProtocolVersion != 1 { + return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) + } + if s.req.StrictResume && !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { + return fmt.Errorf("mcode: native Subagent admission is unavailable") + } + params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} + method := "session/new" + if s.req.AgentSessionID != "" { + method = "session/load" + params["sessionId"] = s.req.AgentSessionID + } + var session sessionResult + if err := s.call(method, params, &session, false); err != nil { + return err + } + if s.req.AgentSessionID != "" { + session.SessionID = s.req.AgentSessionID + } + if session.SessionID == "" { + return fmt.Errorf("mcode: ACP returned an empty session id") + } + s.mu.Lock() + s.sessionID = session.SessionID + s.mu.Unlock() + model, err := advertisedModel(session.ConfigOptions, s.opts.Model) + if err != nil && s.req.AgentSessionID != "" && !slices.ContainsFunc(session.ConfigOptions, func(option configOption) bool { return option.ID == "model" }) { + // Native load omits the selector when its persisted model was removed; selection still validates against the current catalog. + model = "m:custom_provider%3Aoac:" + strings.ReplaceAll(url.QueryEscape(s.opts.Model), "+", "%20") + ":v:" + err = nil + } + if err != nil { + return err + } + s.nativeModel = model + if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { + return err + } + return nil +} + +func (s *Session) executePrompt() error { + prompt, err := s.req.Input.TextOnly() + if err != nil { + return err + } + s.active = true + var result struct { + StopReason string `json:"stopReason"` + } + err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt, s.req.StrictResume)}, &result, true) + s.active = false + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() + if err != nil { + return err + } + s.mu.Lock() + cancelled := s.cancelled + s.mu.Unlock() + if result.StopReason == "cancelled" && cancelled { + return nil + } + if result.StopReason != "end_turn" { + return fmt.Errorf("mcode: prompt stopped (%s)", result.StopReason) + } + return nil +} + +// Select the advertised custom model, rather than using mcode's native default. +func advertisedModel(options []configOption, model string) (string, error) { + for _, option := range options { + if option.ID != "model" { + continue + } + for _, candidate := range option.Options { + parts := strings.Split(candidate.Value, ":") + if len(parts) < 4 || parts[0] != "m" { + continue + } + provider, err := decodeComponent(parts[1]) + if err != nil { + continue + } + id, err := decodeComponent(parts[2]) + if err != nil { + continue + } + if provider == "custom_provider:oac" && id == model && (parts[3] == "u" || (len(parts) == 5 && parts[3] == "v" && parts[4] == "")) { + return candidate.Value, nil + } + } + } + return "", fmt.Errorf("mcode: configured model is not advertised by the CLI") +} + +func (s *Session) call(method string, params any, result any, prompt bool) error { + id, _ := s.reserveResponse() + s.removeResponse(id) + raw, err := json.Marshal(params) + if err != nil { + return err + } + if prompt && s.executor != nil { + // Serialize the admission check with the wire, but release the owner + // mutex before a pipe write so cancellation and Close can stop it. + s.connection.writeMu.Lock() + s.executor.mu.Lock() + s.mu.Lock() + cancelled := s.cancelled + s.mu.Unlock() + if cancelled || s.executor.closed { + s.executor.mu.Unlock() + s.connection.writeMu.Unlock() + return errTurnCancelled + } + s.executor.mu.Unlock() + err = json.NewEncoder(s.process.Stdin).Encode(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) + s.connection.writeMu.Unlock() + } else { + err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) + } + if err != nil { + return err + } + ctx := s.process.Context() + if !prompt { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + } + for { + select { + case <-ctx.Done(): + return fmt.Errorf("mcode: %s: %w", method, ctx.Err()) + case <-s.exited: + return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) + case frame, ok := <-s.frames: + if !ok { + <-s.exited + return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) + } + if frame.Method != "" { + if err := s.handle(frame); err != nil { + return err + } + continue + } + if string(frame.ID) != id { + continue + } + if frame.Error != nil { + return fmt.Errorf("mcode: %s: %s", method, frame.Error.Message) + } + if result != nil { + if err := json.Unmarshal(frame.Result, result); err != nil { + return fmt.Errorf("mcode: invalid %s result", method) + } + } + return nil + } + } +} + +func (s *Session) emit(kind string, payload any) { + ctx := s.ctx + if s.outputContext != nil { + ctx = s.outputContext + } + env, err := proto.NewEnvelope(kind, s.req.RunID, payload) + if err != nil { + return + } + select { + case s.out <- env: + return + default: + } + select { + case s.out <- env: + case <-ctx.Done(): + } +} + +func (s *Session) Cancel(ctx context.Context) error { + if s.executor != nil { + return s.cancelTurn(ctx) + } + if s.req.StrictResume && !s.req.DisableSubagents { + if err := s.cancelSubagents(ctx); err != nil { + s.process.Cancel() + return err + } + } + s.process.Cancel() + if !s.req.StrictResume { + return nil + } + select { + case <-s.exited: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-s.finished: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) SubmitPermission(_ context.Context, id string, decision proto.PermissionDecisionPayload) error { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.permissions[id] + if !ok { + return agent.ErrUnknownPermission + } + choice := pending.Deny + if decision.Approved { + choice = pending.Allow + } + if choice == "" { + return errors.New("mcode: ACP permission option is unavailable") + } + if len(decision.UpdatedInput) > 0 { + return errors.New("mcode: edited permission input is not supported") + } + raw, _ := json.Marshal(map[string]any{"outcome": map[string]string{"outcome": "selected", "optionId": choice}}) + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { + return err + } + delete(s.permissions, id) + return nil +} diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go new file mode 100644 index 000000000..f2af87a50 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -0,0 +1,388 @@ +package mcode + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func testRequest(t *testing.T) proto.PromptRequestPayload { + t.Helper() + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + return proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conversation-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), AgentOptions: map[string]any{ + "model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/v1", "api_key": "fixture-key", "context_window": 64000, "max_output_tokens": 4096}, "system_prompt": "Current instructions", + }} +} + +func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { + t.Helper() + req := testRequest(t) + if scenario == "strict-cancel" { + req = executionRequest(t) + } + if resume { + req.AgentSessionID = "native-1" + } + t.Setenv("OAC_TEST_MCODE_HELPER", scenario) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "mcode") + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=" + scenario + "\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + out := make(chan proto.Envelope, 32) + session, err := newSession(ctx, req, out, binary) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = session.Cancel(context.Background()) + select { + case <-session.exited: + case <-time.After(3 * time.Second): + t.Error("CLI was not reaped") + } + }) + return session, out +} + +func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "resume"}[resume], func(t *testing.T) { + _, out := helperSession(t, "happy", resume) + var content string + tools := map[string]int{} + doneCount := 0 + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("error: %s", event.Payload) + case proto.TypeDelta: + var p proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &p) + content += p.Delta + case proto.TypeToolCall: + var p proto.ToolCallPayload + _ = json.Unmarshal(event.Payload, &p) + tools[p.Stage]++ + case proto.TypeDone: + doneCount++ + var p proto.DonePayload + _ = json.Unmarshal(event.Payload, &p) + 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 { + t.Fatalf("context occupancy reported as usage: %#v", p.Usage) + } + } + } + if content != "Hello world" || doneCount != 1 || tools["before"] != 1 || tools["after"] != 1 { + t.Fatalf("content=%q done=%d tools=%v", content, doneCount, tools) + } + }) + } +} + +func TestSessionInteractionRoundTrip(t *testing.T) { + for _, approved := range []bool{true, false} { + t.Run(map[bool]string{true: "allow", false: "deny"}[approved], func(t *testing.T) { + session, out := helperSession(t, "interaction", false) + permissions, questions := 0, 0 + for event := range out { + switch event.Type { + case proto.TypeError: + t.Fatalf("error: %s", event.Payload) + case proto.TypePermissionRequest: + permissions++ + var p proto.PermissionRequestPayload + _ = json.Unmarshal(event.Payload, &p) + if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: approved}); err != nil { + t.Fatal(err) + } + if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("duplicate approval: %v", err) + } + case proto.TypePromptForUserChoice: + questions++ + var p proto.PromptForUserChoicePayload + _ = json.Unmarshal(event.Payload, &p) + decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe"}}}} + if err := session.SubmitPromptForUserChoice(context.Background(), p.AskID, decision); err != nil { + t.Fatal(err) + } + } + } + if permissions != 1 || questions != 1 { + t.Fatalf("permissions=%d questions=%d", permissions, questions) + } + }) + } +} + +func TestResumeSelectsModelWhenNativeSelectorIsMissing(t *testing.T) { + _, out := helperSession(t, "resume-model", true) + completed := false + for event := range out { + if event.Type == proto.TypeError { + t.Fatalf("resume failed: %s", event.Payload) + } + if event.Type == proto.TypeDone { + completed = true + } + } + if !completed { + t.Fatal("resume did not complete") + } +} + +func TestSessionFailuresAreReported(t *testing.T) { + for _, scenario := range []string{"malformed", "exit", "rpc-error", "unknown-model"} { + t.Run(scenario, func(t *testing.T) { + _, out := helperSession(t, scenario, false) + reported := false + for event := range out { + if event.Type == proto.TypeError { + reported = true + } + } + if !reported { + t.Fatal("failure was not emitted") + } + }) + } +} + +func TestCancelStopsWaitingCLI(t *testing.T) { + session, out := helperSession(t, "hang", false) + if err := session.Cancel(context.Background()); err != nil { + t.Fatal(err) + } + select { + case <-session.exited: + case <-time.After(3 * time.Second): + t.Fatal("cancel hung") + } + for range out { + } +} + +func TestMCodeProcess(t *testing.T) { + scenario := os.Getenv("OAC_TEST_MCODE_HELPER") + if scenario == "" { + return + } + encoder := json.NewEncoder(os.Stdout) + send := func(value any) { + if err := encoder.Encode(value); err != nil { + os.Exit(2) + } + } + update := func(kind string, fields map[string]any) { + fields["sessionUpdate"] = kind + send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "native-1", "update": fields}}) + } + scanner := bufio.NewScanner(os.Stdin) + var promptID json.RawMessage + prompts, configurations := 0, 0 + for scanner.Scan() { + var frame rpcFrame + if json.Unmarshal(scanner.Bytes(), &frame) != nil { + os.Exit(3) + } + if scenario == "malformed" { + os.Stdout.WriteString("invalid JSON\n") + os.Exit(0) + } + if scenario == "exit" { + os.Exit(1) + } + if scenario == "hang" { + continue + } + if record := os.Getenv("OAC_TEST_MCODE_RECORD"); record != "" { + f, err := os.OpenFile(record, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + os.Exit(10) + } + _, _ = f.WriteString(frame.Method + "\n") + _ = f.Close() + if frame.Method == "session/new" || frame.Method == "session/load" { + cwd, err := os.Getwd() + if err != nil || os.WriteFile(record+".cwd", []byte(cwd), 0600) != nil { + os.Exit(11) + } + if os.WriteFile(record+".session", frame.Params, 0600) != nil { + os.Exit(11) + } + } + } + result := any(map[string]any{}) + switch frame.Method { + case "initialize": + result = map[string]int{"protocolVersion": 1} + case "session/new", "session/load": + if scenario == "prepared-mcp-cancel" { + if writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("proof.server", "proof_server", "read.status", "read_status")) != nil { + os.Exit(12) + } + } + if frame.Method == "session/load" { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) + } + model := "m:custom_provider%3Aoac:fixture:v:" + if scenario == "unknown-model" { + model = "m:minimax:native:u" + } + result = map[string]any{"sessionId": "native-1", "configOptions": []map[string]any{{"id": "model", "options": []map[string]string{{"value": model}}}}} + if scenario == "resume-model" { + result = map[string]any{"configOptions": []map[string]any{{"id": "permissionMode"}}} + } + case "session/set_config_option": + configurations++ + if scenario == "executor-backpressure" && configurations > 1 { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: json.RawMessage("{}")}) + time.Sleep(time.Minute) + os.Exit(0) + } + if scenario == "executor-preexit" && configurations > 1 { + os.Exit(0) + } + var params map[string]string + _ = json.Unmarshal(frame.Params, ¶ms) + if params["configId"] == "model" && params["value"] != "m:custom_provider%3Aoac:fixture:v:" { + os.Exit(4) + } + case "session/prompt": + var input struct { + Prompt []map[string]string `json:"prompt"` + } + _ = json.Unmarshal(frame.Params, &input) + if strict := scenario == "strict-cancel" || (strings.HasPrefix(scenario, "prepared") || strings.HasPrefix(scenario, "executor")); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + os.Exit(9) + } + if strings.HasPrefix(scenario, "executor") { + prompts++ + promptID = frame.ID + if scenario == "executor-exit" { + os.Exit(0) + } + if input.Prompt[0]["text"] == "wait" { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + continue + } + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) + update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "Read", "status": "in_progress", "rawInput": map[string]any{}}) + update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + // These frames precede the next control barrier on the wire and + // must never become the following Turn's output. + for range 100 { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD"}}) + } + continue + } + if scenario == "prepared-mcp-cancel" { + promptID = frame.ID + update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) + continue + } + if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { + promptID = frame.ID + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + continue + } + if scenario == "many-frames" || scenario == "prepared" { + for range 100 { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "x"}}) + } + result = map[string]string{"stopReason": "end_turn"} + break + } + if scenario == "rpc-error" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32603, Message: "Fixture provider unavailable"}}) + continue + } + if scenario == "interaction" { + promptID = frame.ID + send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "always", "kind": "allow_always"}, {"optionId": "deny", "kind": "reject_once"}}}}) + continue + } + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) + update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "Read", "status": "in_progress", "rawInput": map[string]any{"path": "fixture.txt"}}) + for range 2 { + update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) + } + update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) + result = map[string]string{"stopReason": "end_turn"} + case "mcode/session/delegation/stop": + result = map[string]any{"receipt": map[string]any{"failedSessionIds": []string{}}} + case "session/cancel": + raw, _ := json.Marshal(map[string]string{"stopReason": "cancelled"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + continue + case "mcode/session/steer": + if scenario == "executor-steer-unknown" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32000, Message: "Unknown input outcome"}}) + continue + } + if scenario == "steer-lost" { + os.Exit(0) + } + if scenario == "steer-rejected" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "inactive"}}) + continue + } + raw, _ := json.Marshal(map[string]string{"turnId": "native-turn", "mode": "steered"}) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "-steered"}}) + raw, _ = json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + continue + case "": + if string(frame.ID) == `"permission-1"` { + var reply struct { + Outcome struct { + Option string `json:"optionId"` + } `json:"outcome"` + } + _ = json.Unmarshal(frame.Result, &reply) + if reply.Outcome.Option != "once" && reply.Outcome.Option != "deny" { + os.Exit(5) + } + send(map[string]any{"jsonrpc": "2.0", "id": "question-1", "method": "elicitation/create", "params": map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "required": []string{"region"}, "properties": map[string]any{"region": map[string]any{"type": "string", "title": "Region?", "oneOf": []map[string]string{{"const": "eu", "title": "Europe"}, {"const": "us", "title": "America"}}}}}}}) + } else { + var reply struct { + Action string `json:"action"` + Content map[string]string `json:"content"` + } + _ = json.Unmarshal(frame.Result, &reply) + if reply.Action != "accept" || reply.Content["region"] != "eu" { + os.Exit(6) + } + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + } + continue + } + raw, _ := json.Marshal(result) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + } + os.Exit(0) +} diff --git a/apps/daemon/internal/agent/mcode/steering.go b/apps/daemon/internal/agent/mcode/steering.go new file mode 100644 index 000000000..bff6f079f --- /dev/null +++ b/apps/daemon/internal/agent/mcode/steering.go @@ -0,0 +1,130 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +var _ agent.DurableSteerer = (*Session)(nil) + +func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, input, nil) +} + +// Native acceptance belongs to the active ACP Turn; it does not promise model consumption. +func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if s.executor != nil { + s.mu.Lock() + if s.closing || s.cancelled { + s.mu.Unlock() + return agent.ErrSteeringInactive + } + s.operations.Add(1) + s.mu.Unlock() + defer s.operations.Done() + } + text, err := input.Input.TextOnly() + if strings.TrimSpace(input.InputID) == "" || err != nil { + return agent.ErrSteeringRejected + } + s.mu.Lock() + ready, native := s.steeringReady, s.sessionID + s.mu.Unlock() + if s.process.Context().Err() != nil { + return agent.ErrSteeringInactive + } + if !ready || native == "" { + return agent.ErrSteeringNotReady + } + id, response := s.reserveResponse() + defer s.removeResponse(id) + raw, err := json.Marshal(map[string]string{"sessionId": native, "text": text, "clientRequestId": input.InputID}) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) + if err != nil { + s.markInputUncertain() + return fmt.Errorf("mcode: input transport failed") + } + if written != nil { + written() + } + var frame rpcFrame + var stopped error + select { + case frame = <-response: + case <-s.inputSettlementDone(): + stopped = fmt.Errorf("mcode: run ended with unknown input outcome") + case <-s.exited: + stopped = fmt.Errorf("mcode: input transport closed") + case <-ctx.Done(): + stopped = ctx.Err() + } + if stopped != nil { + // A native receipt already read before terminal closure remains authoritative. + select { + case frame = <-response: + default: + s.markInputUncertain() + return stopped + } + } + if frame.Error != nil { + if frame.Error.Code == -32602 || frame.Error.Code == -32601 { + return agent.ErrSteeringRejected + } + s.markInputUncertain() + return fmt.Errorf("mcode: native steering failed with unknown input outcome") + } + var result struct { + TurnID string `json:"turnId"` + Mode string `json:"mode"` + } + if json.Unmarshal(frame.Result, &result) != nil || result.TurnID == "" || (result.Mode != "steered" && result.Mode != "duplicate") { + s.markInputUncertain() + return fmt.Errorf("mcode: invalid steering receipt") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.steeringTurn != "" && s.steeringTurn != result.TurnID { + s.inputUncertain = true + return fmt.Errorf("mcode: steering turn changed") + } + s.steeringTurn = result.TurnID + return nil +} + +func (s *Session) inputSettlementDone() <-chan struct{} { + if s.inputDone != nil { + return s.inputDone + } + return s.finished +} + +func (s *Session) CancellationOutcome() proto.DonePayload { + s.mu.Lock() + defer s.mu.Unlock() + if s.outcome.Metadata != nil { + return s.outcome + } + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} + if s.sessionID != "" { + metadata[proto.DoneMetaAgentSessionID] = s.sessionID + } + return proto.DonePayload{Metadata: metadata} +} + +func (s *Session) markInputUncertain() { + s.mu.Lock() + s.inputUncertain = true + s.mu.Unlock() +} diff --git a/apps/daemon/internal/agent/mcode/steering_test.go b/apps/daemon/internal/agent/mcode/steering_test.go new file mode 100644 index 000000000..2c4cc517a --- /dev/null +++ b/apps/daemon/internal/agent/mcode/steering_test.go @@ -0,0 +1,126 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestNativeSteeringReceipt(t *testing.T) { + for _, scenario := range []string{"steering", "steer-rejected", "steer-lost"} { + t.Run(scenario, func(t *testing.T) { + s, out := helperSession(t, scenario, false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatalf("first event %s", event.Type) + } + case <-ctx.Done(): + t.Fatal("not ready") + } + written := false + reply := make(chan error, 1) + go func() { + reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("next")}, func() { written = true }) + }() + // Drain native output concurrently, as the router does. + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + err := <-reply + if !written { + t.Fatal("complete write was not reported") + } + switch scenario { + case "steering": + if err != nil { + t.Fatal(err) + } + case "steer-rejected": + if !errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("got %v", err) + } + case "steer-lost": + if err == nil || errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("unknown outcome became rejection/success: %v", err) + } + } + s.Cancel(ctx) + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + }) + } +} + +func TestTerminalFollowsAllNativeFrames(t *testing.T) { + _, out := helperSession(t, "many-frames", false) + count := 0 + for e := range out { + switch e.Type { + case proto.TypeDelta: + count++ + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(e.Payload, &done) + if count != 100 || len(done.Content) != 100 { + t.Fatalf("terminal overtook frames: %d/%d", count, len(done.Content)) + } + case proto.TypeError: + t.Fatalf("unexpected error %s", e.Payload) + } + } +} + +func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { + s, out := helperSession(t, "strict-cancel", false) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + select { + case <-out: + case <-ctx.Done(): + t.Fatal("not ready") + } + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + if err := s.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case <-s.exited: + default: + t.Fatal("cancel returned before process exit") + } + select { + case <-s.finished: + default: + t.Fatal("cancel returned before output settlement") + } + if err := s.Cancel(ctx); err != nil { + t.Fatal("duplicate cancellation", err) + } + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + if s.CancellationOutcome().Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("lost native binding") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go b/apps/daemon/internal/agent/mcode/subagent_cancel.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go rename to apps/daemon/internal/agent/mcode/subagent_cancel.go diff --git a/apps/daemon/internal/agent/mcode/subagent_coordination.go b/apps/daemon/internal/agent/mcode/subagent_coordination.go new file mode 100644 index 000000000..a6b32ce89 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/subagent_coordination.go @@ -0,0 +1,52 @@ +package mcode + +import ( + "encoding/json" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func nativeCoordination(session nativeSubagentSession, tool nativeSubagentTool) (*proto.SubagentCoordinationPayload, error) { + if tool.Status != 2 || (tool.Name != "task" && tool.Name != "task_append") { + return nil, nil + } + var args struct { + Prompt, Content string + TaskID string `json:"task_id"` + } + if json.Unmarshal([]byte(tool.Args), &args) != nil { + return nil, fmt.Errorf("mcode: invalid delegation arguments") + } + value := &proto.SubagentCoordinationPayload{ID: tool.ID, ActorID: session.ID, Status: "completed"} + if tool.Name == "task" { + value.Kind, value.Text = "create_subagent_call", &args.Prompt + for _, task := range session.Tasks { + if task.ToolCallID == tool.ID && task.Metadata.ExecutionMode != "append" { + value.Recipients = []string{task.Metadata.ChildSessionID} + break + } + } + } else { + var result struct { + Details struct { + Accepted bool `json:"accepted"` + TaskID string `json:"task_id"` + } `json:"details"` + } + if json.Unmarshal([]byte(tool.Result), &result) != nil || !result.Details.Accepted { + return nil, fmt.Errorf("mcode: delegation receipt is missing") + } + value.Kind, value.Text = "send_subagent_input_call", &args.Content + for _, task := range session.Tasks { + if task.TaskID == result.Details.TaskID { + value.Recipients = []string{task.Metadata.ChildSessionID} + break + } + } + } + if len(value.Recipients) != 1 || value.Recipients[0] == "" { + return nil, fmt.Errorf("mcode: successful delegation lacks child identity") + } + return value, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_messages.go b/apps/daemon/internal/agent/mcode/subagent_messages.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcode/subagent_messages.go rename to apps/daemon/internal/agent/mcode/subagent_messages.go index f309dad48..00f20d105 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagent_messages.go +++ b/apps/daemon/internal/agent/mcode/subagent_messages.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *Session) projectSubagentMessages(session nativeSubagentSession, turn nativeSubagentTurn) error { diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go new file mode 100644 index 000000000..65c05a7b2 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -0,0 +1,217 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type nativeSubagentSnapshot struct { + Version int `json:"version"` + Root string `json:"rootSessionId"` + Complete bool `json:"complete"` + Sessions []nativeSubagentSession `json:"sessions"` +} +type nativeSubagentSession struct { + ID, Parent, Name, Kind, Purpose, Status string + CreatedAt int64 + Turns []nativeSubagentTurn + Messages []nativeSubagentMessage + Tasks []nativeSubagentTask +} +type nativeSubagentTurn struct { + ID, Status string + CreatedAt int64 + CompletedAt *int64 +} +type nativeSubagentMessage struct { + ID, TurnID, Role string + CreatedAt int64 + Data struct { + Text string `json:"msg_content"` + Thinking string `json:"thinking_content"` + Tools []nativeSubagentTool `json:"tool_calls"` + } +} +type nativeSubagentTool struct { + ID string `json:"tool_call_id"` + Name string `json:"tool_name"` + Status int `json:"tool_call_status"` + Args string `json:"tool_call_args"` + Result string `json:"tool_call_result_data"` +} +type nativeSubagentTask struct { + TaskID, ToolCallID, OwnerSessionID, Status string + Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } +} + +func subagentReader() (string, string, error) { + node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") + reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") + for _, path := range []string{node, bridge, reader} { + resolved, err := filepath.EvalSymlinks(path) + if err != nil || !filepath.IsAbs(path) || resolved != path { + return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") + } + } + return node, reader, nil +} + +func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { + var snapshot nativeSubagentSnapshot + node, reader, err := subagentReader() + if err != nil { + return snapshot, err + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, node, "--disable-warning=ExperimentalWarning", reader, s.opts.DataDir, s.sessionID) + command.Env = executionEnvironment() + stdout, err := command.StdoutPipe() + if err != nil { + return snapshot, fmt.Errorf("mcode: child history reader is unavailable") + } + if err := command.Start(); err != nil { + return snapshot, fmt.Errorf("mcode: child history reader is unavailable") + } + raw, readErr := io.ReadAll(io.LimitReader(stdout, 64*1024*1024+1)) + if readErr != nil || len(raw) > 64*1024*1024 { + _ = command.Process.Kill() + } + err = command.Wait() + if err != nil || readErr != nil || len(raw) > 64*1024*1024 { + return snapshot, fmt.Errorf("mcode: complete child history is unavailable") + } + if json.Unmarshal(raw, &snapshot) != nil || snapshot.Version != 1 || !snapshot.Complete || snapshot.Root != s.sessionID { + return snapshot, fmt.Errorf("mcode: invalid child history snapshot") + } + return snapshot, nil +} + +func (s *Session) settleSubagents() error { + if s.req.DisableSubagents { + return nil + } + ctx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) + defer cancel() + for { + snapshot, err := s.readSubagents(ctx) + if err != nil { + return err + } + settled := true + for _, session := range snapshot.Sessions { + if session.ID == snapshot.Root && s.rootCompletedAtMS == nil { + for _, turn := range session.Turns { + if !s.previousNativeTurns[turn.ID] && turn.Status == "completed" && turn.CompletedAt != nil { + s.rootCompletedAtMS = turn.CompletedAt + break + } + } + } + for _, task := range session.Tasks { + if task.Status == "queued" || task.Status == "running" || task.Status == "stopping" { + settled = false + } + } + for _, turn := range session.Turns { + if turn.Status == "accepted" { + settled = false + } + } + } + if settled { + return s.projectSubagents(snapshot) + } + // Root output is frozen. Drain native notifications while its existing + // background task owners finish; none becomes a root output delta. + select { + case <-ctx.Done(): + return fmt.Errorf("mcode: child settlement did not complete") + case _, ok := <-s.frames: + if !ok { + return fmt.Errorf("mcode: native owner ended before child settlement") + } + case <-time.After(200 * time.Millisecond): + } + } +} + +func (s *Session) projectSubagents(snapshot nativeSubagentSnapshot) error { + byID := map[string]nativeSubagentSession{} + tasks := map[string]nativeSubagentTask{} + for _, session := range snapshot.Sessions { + if session.ID == "" || byID[session.ID].ID != "" { + return fmt.Errorf("mcode: duplicate native child identity") + } + byID[session.ID] = session + for _, task := range session.Tasks { + if task.Metadata.ExecutionMode != "append" && task.Metadata.ChildSessionID != "" { + tasks[task.Metadata.ChildSessionID] = task + } + } + } + emitted := map[string]bool{snapshot.Root: true} + for len(emitted) < len(byID) { + progress := false + for _, session := range snapshot.Sessions { + if emitted[session.ID] || !emitted[session.Parent] { + continue + } + task, ok := tasks[session.ID] + if !ok || session.Kind != "task" || session.CreatedAt <= 0 || task.OwnerSessionID != session.Parent || task.Metadata.ParentTurnID == "" || task.ToolCallID == "" { + return fmt.Errorf("mcode: native child provenance is incomplete") + } + name := session.Name + s.emit(proto.TypeSubagentIdentity, proto.SubagentIdentityPayload{NativeID: session.ID, ParentNativeID: session.Parent, NativeCreatedAt: session.CreatedAt / 1000, ParentTurnID: task.Metadata.ParentTurnID, SourceItemID: task.ToolCallID, Name: &name}) + emitted[session.ID], progress = true, true + } + if !progress { + return fmt.Errorf("mcode: native child graph is incomplete") + } + } + for _, session := range snapshot.Sessions { + if session.ID == snapshot.Root { + for _, message := range session.Messages { + if s.previousNativeTurns[message.TurnID] { + continue + } + for _, tool := range message.Data.Tools { + value, err := nativeCoordination(session, tool) + if err != nil { + return err + } + if value != nil { + value.ActorID = "" + s.emit(proto.TypeSubagentCoordination, value) + } + } + } + continue + } + for _, turn := range session.Turns { + if turn.ID == "" || turn.CreatedAt <= 0 { + return fmt.Errorf("mcode: invalid child Turn") + } + status := map[string]string{"accepted": "in_progress", "completed": "completed", "failed": "failed", "aborted": "cancelled"}[turn.Status] + if status == "" || (status != "in_progress" && turn.CompletedAt == nil) { + return fmt.Errorf("mcode: incomplete child Turn boundary") + } + value := proto.SubagentTurnPayload{NativeID: session.ID, TurnID: turn.ID, Status: "in_progress", CreatedAtMS: turn.CreatedAt} + s.emit(proto.TypeSubagentTurn, value) + if err := s.projectSubagentMessages(session, turn); err != nil { + return err + } + value.Status, value.CompletedAtMS = status, turn.CompletedAt + s.emit(proto.TypeSubagentTurn, value) + } + } + return nil +} diff --git a/apps/daemon/internal/agent/mcode/subagents_test.go b/apps/daemon/internal/agent/mcode/subagents_test.go new file mode 100644 index 000000000..bccb15597 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/subagents_test.go @@ -0,0 +1,102 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func childSnapshot(t *testing.T) nativeSubagentSnapshot { + t.Helper() + var snapshot nativeSubagentSnapshot + const raw = `{"version":1,"complete":true,"rootSessionId":"root","sessions":[ + {"id":"root","turns":[{"id":"parent-turn","status":"completed","createdAt":1000,"completedAt":2000}],"tasks":[{"taskId":"task","ownerSessionId":"root","toolCallId":"spawn","status":"succeeded","metadata":{"childSessionId":"child","parentTurnId":"parent-turn","executionMode":"foreground"}}]}, + {"id":"child","parent":"root","kind":"task","name":"worker","createdAt":1100,"turns":[{"id":"turn","status":"completed","createdAt":1200,"completedAt":1800}],"messages":[{"id":"input","turnId":"turn","role":"user","data":{"msg_content":"child input"}},{"id":"answer","turnId":"turn","role":"assistant","data":{"msg_content":"child output","thinking_content":"native reasoning"}}]}]}` + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { + t.Fatal(err) + } + return snapshot +} + +func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { + project := func(snapshot nativeSubagentSnapshot) []proto.Envelope { + out := make(chan proto.Envelope, 32) + s := &Session{ctx: context.Background(), out: out, previousNativeTurns: map[string]bool{}} + if err := s.projectSubagents(snapshot); err != nil { + t.Fatal(err) + } + close(out) + var events []proto.Envelope + for event := range out { + events = append(events, event) + } + return events + } + first := project(childSnapshot(t)) + second := project(childSnapshot(t)) + if len(first) != 6 { + t.Fatalf("got %d events", len(first)) + } + for i, event := range first { + if event.Type != second[i].Type || !reflect.DeepEqual(event.Payload, second[i].Payload) { + t.Fatal("cold snapshot identities changed") + } + } + if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[5].Type != proto.TypeSubagentTurn { + t.Fatal("identity/turn/item ordering changed") + } + var input proto.SubagentItemPayload + if err := json.Unmarshal(first[2].Payload, &input); err != nil { + t.Fatal(err) + } + if input.NativeID != "child" || input.TurnID != "turn" || input.ItemID != "input" || input.Kind != "message" { + t.Fatalf("invalid child ownership: %+v", input) + } +} + +func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { + snapshot := childSnapshot(t) + snapshot.Sessions[0].Tasks = nil + s := &Session{ctx: context.Background(), out: make(chan proto.Envelope, 32)} + if err := s.projectSubagents(snapshot); err == nil { + t.Fatal("accepted child without original spawning provenance") + } +} + +// The ACP cancelled response may precede the root native Turn becoming terminal. +func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { + dir := t.TempDir() + node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + for name, data := range map[string]string{ + node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", + bridge: "", + filepath.Join(dir, "subagent-snapshot.mjs"): "", + } { + if err := os.WriteFile(name, []byte(data), 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_RUNTIME_MCODE_NODE", node) + t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + for _, status := range []string{"accepted", "aborted"} { + t.Run(status, func(t *testing.T) { + raw := []byte(`{"version":1,"complete":true,"rootSessionId":"root","sessions":[{"id":"root","turns":[{"id":"current","status":"` + status + `"}]}]}`) + if err := os.WriteFile(filepath.Join(dir, "snapshot.json"), raw, 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + s := &Session{ctx: ctx, sessionID: "root", opts: launchOptions{DataDir: dir}, frames: make(chan rpcFrame)} + err := s.settleSubagents() + if (err != nil) != (status == "accepted") { + t.Fatalf("root %s settlement error = %v", status, err) + } + }) + } +} diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go new file mode 100644 index 000000000..789b8f1ab --- /dev/null +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -0,0 +1,131 @@ +package mcode + +import ( + "bytes" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/google/uuid" +) + +func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T) { + config, req, _ := workspaceFixture(t) + source := filepath.Join(t.TempDir(), "operator.json") + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + write(source, `{"MCP_TEST_TOKEN":"fixture-bearer-canary","TOOL_SECRET":"fixture-tool-canary"}`) + initialization := t.TempDir() + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", initialization) + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", source) + plugin := t.TempDir() + 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()) + t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", "enabled") + t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", "") + req.WorkDir, req.AgentStateKey = "", "agents-api-"+session + req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory + req.LocalEnvironment.Capabilities = true + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{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() + if err != nil { + t.Fatal(err) + } + configured, err := binding.Configure(req) + if err != nil { + t.Fatal(err) + } + prepared, err := binding.Prepare(t.Context(), configured) + if err != nil { + t.Fatal(err) + } + opts, err := prepareWorkspaceOptions(t.Context(), config, prepared) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) + if err != nil { + t.Fatal(err) + } + var profile struct { + ToolEnvFile string `json:"toolEnvFile"` + } + if err := json.Unmarshal(raw, &profile); err != nil { + t.Fatal(err) + } + if profile.ToolEnvFile != filepath.Join(initialization, "tool-env.json") { + t.Fatal("native profile did not retain the Runtime snapshot reference") + } + // Assert the actual env-selected HTTP credential, not a manually injected token. + headers, ok := opts.MCP[1]["headers"].([]map[string]string) + if !ok || len(headers) != 1 || headers[0]["name"] != "Authorization" || headers[0]["value"] != "Bearer fixture-bearer-canary" { + t.Fatal("frozen MCP credential was lost or replaced") + } + if err := filepath.WalkDir(opts.DataDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(body, []byte("fixture-bearer-canary")) || bytes.Contains(body, []byte("fixture-tool-canary")) { + t.Fatal("tool credential copied into native persisted state") + } + return nil + }); err != nil { + t.Fatal(err) + } + } + if err := os.Remove(filepath.Join(initialization, "tool-env.json")); err != nil { + t.Fatal(err) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + configured, err := binding.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err := binding.Prepare(t.Context(), configured); err == nil { + t.Fatal("missing frozen credential source was recreated or fell back to anonymous") + } +} + +func TestWorkspaceRejectsInvalidToolEnvironment(t *testing.T) { + config, req, _ := workspaceFixture(t) + file := filepath.Join(t.TempDir(), "tool-env.json") + if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) + if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + t.Fatal("invalid explicit tool configuration was ignored") + } +} diff --git a/apps/daemon/internal/agent/mcode/tool_observations.go b/apps/daemon/internal/agent/mcode/tool_observations.go new file mode 100644 index 000000000..7f6ba9f9c --- /dev/null +++ b/apps/daemon/internal/agent/mcode/tool_observations.go @@ -0,0 +1,63 @@ +package mcode + +import ( + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (s *Session) emitToolStage(update toolUpdate, stage string) error { + payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} + if stage == "after" { + payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} + } + if s.req.ObserveToolObservations { + payload.Observation = workspaceToolObservation(update, stage) + if payload.Observation == nil { + var err error + payload.Observation, err = environmentMCPObservation(update, stage) + if err != nil { + return err + } + } + // Native task/skill bookkeeping has no qualified public item mapping. + if payload.Observation == nil { + return nil + } + } + s.emit(proto.TypeToolCall, payload) + return nil +} + +func workspaceToolObservation(update toolUpdate, stage string) *proto.ToolObservation { + if update.Name != "mcp__oac_workspace__workspace_bash" { + return nil + } + command, _ := update.RawInput["command"].(string) + if command == "" { + return nil + } + cwd := "/workspace" + result := &proto.ToolObservation{Kind: "command", Command: command, Cwd: &cwd, Status: "in_progress"} + if stage == "after" { + result.Status = update.Status + // ACP reports MCP content but no structured exit code or duration. + raw, _ := json.Marshal(update.RawOutput) + var output struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &output) == nil && output.Content != nil { + text := "" + for _, part := range output.Content { + if part.Type == "text" { + text += part.Text + } + } + result.Output, _ = json.Marshal(text) + } + } + return result +} diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go b/apps/daemon/internal/agent/mcode/tool_observations_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go rename to apps/daemon/internal/agent/mcode/tool_observations_test.go index b9936f765..e9011d503 100644 --- a/apps/parsar-daemon/internal/agent/mcode/tool_observations_test.go +++ b/apps/daemon/internal/agent/mcode/tool_observations_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceCommandObservationsWaitForArgumentsAndRetainOutcome(t *testing.T) { diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go new file mode 100644 index 000000000..a92be890d --- /dev/null +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -0,0 +1,48 @@ +package mcode + +import ( + "context" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { + return fmt.Errorf("%w: native public function tools are not qualified", agent.ErrUnsupportedOperation) +} + +func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported +} + +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 +} + +func (s *prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported +} + +func (s *prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { + return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported +} + +func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { + return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported +} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go new file mode 100644 index 000000000..a059b11ef --- /dev/null +++ b/apps/daemon/internal/agent/mcode/unsupported_test.go @@ -0,0 +1,51 @@ +package mcode + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Nil concrete receivers prove Unsupported needs no native owner, transport, +// 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") + } + } + var turn *Session + check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) + for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + result, err := owner.ReadWorkspaceFile(ctx, secret, 1) + check(err) + if len(result.Data) != 0 || result.Truncated { + t.Fatal("unsupported read fabricated data") + } + } + for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) + check(err) + if len(result.Entries) != 0 || result.Truncated { + t.Fatal("unsupported listing fabricated entries") + } + } + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + 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 new file mode 100644 index 000000000..95c476007 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/version.go @@ -0,0 +1,24 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" +) + +var ErrCLINotFound = errors.New("mcode CLI not found") + +const SupportedVersion = "0.4.12" + +func defaultBinary() string { return binpath.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}) + if err == nil && version != SupportedVersion { + err = fmt.Errorf("mcode: unsupported version %s; install %s", version, SupportedVersion) + } + return version, err +} diff --git a/apps/parsar-daemon/internal/agent/mcode/version_test.go b/apps/daemon/internal/agent/mcode/version_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/version_test.go rename to apps/daemon/internal/agent/mcode/version_test.go diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go new file mode 100644 index 000000000..293445ccc --- /dev/null +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -0,0 +1,163 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" +) + +// WorkspaceConfig is frozen deployment input, separate from public Agent options. +type WorkspaceConfig struct { + Binary, Node, Bridge, Directory, Network, Scratch string + AllowedDomains []string +} + +func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy) (WorkspaceConfig, error) { + c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), + Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} + if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { + return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") + } + if network.Validate() != nil { + return c, fmt.Errorf("mcode: explicit workspace network policy is required") + } + for _, path := range []string{binary, node, bridge, root, workspace} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { + return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") + } + } + for _, path := range []string{binary, node, bridge} { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return c, fmt.Errorf("mcode: runtime program unavailable") + } + } + bound, err := os.Stat(workspace) + if err != nil || !bound.IsDir() { + return c, fmt.Errorf("mcode: workspace directory unavailable") + } + + if err := os.MkdirAll(c.Scratch, 0700); err != nil { + return c, err + } + return c, nil +} + +func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { + if c.Network != "enabled" || len(c.AllowedDomains) != 0 { + return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") + } + if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { + return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") + } + servers, err := runtimeMCP(req) + if err != nil { + return launchOptions{}, err + } + // Reuse public option validation and private Session state provisioning. + // The native process, ACP Session and workspace tools share the declared cwd. + private := req + private.LocalEnvironment, private.WorkDir, private.DisableExecutionEnvironment = nil, "", true + // Public declarations have already been resolved into the transient ACP map. + private.MCPHTTPServers = nil + opts, err := prepareOptionsWithSkills(ctx, private, false) + if err != nil { + return opts, err + } + opts.Dir = c.Directory + if len(req.LocalEnvironment.Skills) > 0 { + root := filepath.Join(opts.DataDir, "skills") + if err := os.MkdirAll(root, 0700); err != nil { + return opts, err + } + for _, skill := range req.LocalEnvironment.Skills { + link, target := filepath.Join(root, skill.Metadata.Name), localworkspace.SkillPath(skill) + actual, err := os.Readlink(link) + if err == nil { + if actual != target { + return opts, fmt.Errorf("mcode: unexpected native Skill root") + } + } else if !os.IsNotExist(err) { + return opts, err + } else if err := os.Symlink(target, link); err != nil { + return opts, err + } + } + } + + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + return opts, err + } + var config map[string]any + if err = json.Unmarshal(raw, &config); err != nil { + return opts, err + } + config["permissionMode"] = "bypassPermissions" + config["sandbox"] = map[string]bool{"enabled": false} + if len(req.LocalEnvironment.Skills) > 0 { + selected := config["agents"].(map[string]any)["default"].(map[string]any) + names := make([]string, 0, len(req.LocalEnvironment.Skills)) + for _, skill := range req.LocalEnvironment.Skills { + names = append(names, skill.Metadata.Name) + } + selected["skills"] = names + for _, key := range []string{"tools", "builtinTools"} { + selected[key] = append(selected[key].([]any), "skill") + } + } + raw, err = json.Marshal(config) + if err != nil { + return opts, err + } + if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { + return opts, err + } + profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} + + profile["workspace"] = c.Directory + { + file, err := localworkspace.ToolEnvironmentFile() + if err != nil { + return opts, err + } + if file != "" { + profile["toolEnvFile"] = file + } + } + + raw, err = json.Marshal(profile) + if err != nil { + return opts, err + } + path := filepath.Join(opts.DataDir, "workspace-profile.json") + if err = os.WriteFile(path, raw, 0600); err != nil { + return opts, err + } + opts.MCP = []map[string]any{{"name": "oac_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} + opts.MCP = append(opts.MCP, servers...) + if !req.DisableSubagents { + // This native data directory belongs to one public Session and its + // descendants. ACP's ephemeral server map otherwise covers only root. + configured := map[string]any{} + for _, server := range opts.MCP[:1] { + name, _ := server["name"].(string) + configured[name] = map[string]any{"type": "stdio", "command": server["command"], "args": server["args"], "env": map[string]string{}, "enabled": true} + } + raw, err := json.Marshal(map[string]any{"mcpServers": configured}) + if err != nil { + return opts, err + } + if err := os.WriteFile(filepath.Join(opts.DataDir, "mcp.json"), raw, 0o600); err != nil { + return opts, err + } + } + return opts, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go b/apps/daemon/internal/agent/mcode/workspace_network_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go rename to apps/daemon/internal/agent/mcode/workspace_network_test.go diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go b/apps/daemon/internal/agent/mcode/workspace_readiness.go similarity index 95% rename from apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go rename to apps/daemon/internal/agent/mcode/workspace_readiness.go index 85d1b4535..b730745b4 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" ) // CheckWorkspace verifies the installed companion. Public qualification remains diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go rename to apps/daemon/internal/agent/mcode/workspace_readiness_test.go diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go similarity index 93% rename from apps/parsar-daemon/internal/agent/mcode/workspace_skills_test.go rename to apps/daemon/internal/agent/mcode/workspace_skills_test.go index 4ea755dfb..ed89e029f 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -7,8 +7,8 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/mcp.go b/apps/daemon/internal/agent/mcp.go similarity index 100% rename from apps/parsar-daemon/internal/agent/mcp.go rename to apps/daemon/internal/agent/mcp.go diff --git a/apps/parsar-daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go similarity index 98% rename from apps/parsar-daemon/internal/agent/mcp_binding.go rename to apps/daemon/internal/agent/mcp_binding.go index 0d8cad5a5..a59eb8091 100644 --- a/apps/parsar-daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -7,7 +7,7 @@ import ( "slices" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // MCPBinding is the Runtime's transient effective declaration. Adapters project diff --git a/apps/parsar-daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/mcp_binding_test.go rename to apps/daemon/internal/agent/mcp_binding_test.go index 02febe3a8..e1a020ede 100644 --- a/apps/parsar-daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -3,8 +3,8 @@ package agent import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { diff --git a/apps/daemon/internal/agent/opencode/export_test.go b/apps/daemon/internal/agent/opencode/export_test.go new file mode 100644 index 000000000..07b9bdc1b --- /dev/null +++ b/apps/daemon/internal/agent/opencode/export_test.go @@ -0,0 +1,36 @@ +package opencode + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + OpenCodeBinary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + opencodeBinary: cfg.OpenCodeBinary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +type Translation = translation + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} diff --git a/apps/daemon/internal/agent/opencode/options.go b/apps/daemon/internal/agent/opencode/options.go new file mode 100644 index 000000000..2145d1170 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/options.go @@ -0,0 +1,312 @@ +package opencode + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" +) + +// BuildResult is the opencode CLI launch plan for one prompt. +type BuildResult struct { + Args []string + Env []string + WorkDir string + ModelSelector string + Cleanup func() +} + +// BuildArgs translates the daemon prompt_request into an `opencode +// run` invocation. +func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, error) { + cleanup := func() {} + result := BuildResult{Cleanup: cleanup} + + resolvedWorkDir, err := resolveWorkDir(workDir) + if err != nil { + return result, err + } + + promptText, err := buildPrompt(prompt, opts) + if err != nil { + return result, err + } + + args := []string{"run", "--format", "json"} + if resolvedWorkDir != "" { + args = append(args, "--dir", resolvedWorkDir) + } + if model := firstString(opts, "model_selector", "model"); model != "" { + args = append(args, "--model", model) + result.ModelSelector = model + } + if agent := stringOpt(opts, "agent"); agent != "" { + args = append(args, "--agent", agent) + } + if boolOpt(opts, "dangerously_skip_permissions") { + args = append(args, "--dangerously-skip-permissions") + } + args = append(args, promptText) + + env, err := buildEnv(opts) + if err != nil { + return result, err + } + + rawConfig := stringOpt(opts, "opencode_json") + if servers, ok := opts["mcp_servers"]; ok && servers != nil { + rawConfig, err = mergeMCPConfig(rawConfig, servers) + if err != nil { + return result, err + } + } + if rawConfig != "" { + configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) + if err != nil { + return result, err + } + cleanup = scratchCleanup + env = append(env, "XDG_CONFIG_HOME="+configHome) + } + + result.Args = args + result.Env = env + result.WorkDir = resolvedWorkDir + result.Cleanup = cleanup + return result, nil +} + +func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + } + servers, ok := rawServers.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) + } + mcp, _ := config["mcp"].(map[string]any) + if mcp == nil { + mcp = map[string]any{} + } + for name, raw := range servers { + entry, ok := raw.(map[string]any) + if !ok { + return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) + } + enabled := true + if value, ok := entry["enabled"].(bool); ok { + enabled = value + } + if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { + remote := map[string]any{ + "type": "remote", + "url": strings.TrimSpace(remoteURL), + "enabled": enabled, + } + if headers := stringMap(entry["headers"]); len(headers) > 0 { + remote["headers"] = headers + } + mcp[name] = remote + continue + } + command, ok := entry["command"].(string) + if !ok || strings.TrimSpace(command) == "" { + return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) + } + commandParts := []string{command} + if args, ok := entry["args"].([]any); ok { + for _, arg := range args { + if value, ok := arg.(string); ok { + commandParts = append(commandParts, value) + } + } + } else if args, ok := entry["args"].([]string); ok { + commandParts = append(commandParts, args...) + } + local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} + if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { + local["environment"] = env + } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { + local["environment"] = env + } + mcp[name] = local + } + if len(mcp) > 0 { + config["mcp"] = mcp + } + encoded, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) + } + return string(encoded), nil +} + +func stringMap(value any) map[string]string { + switch typed := value.(type) { + case map[string]string: + return typed + case map[string]any: + result := make(map[string]string, len(typed)) + for key, raw := range typed { + if text, ok := raw.(string); ok { + result[key] = text + } + } + return result + default: + return nil + } +} + +func resolveWorkDir(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("opencode: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("opencode: work_dir must be absolute or start with ~/, got %q", trimmed) + } + // Match claudecode + codex: mkdir -p so the wizard's "Created if + // it does not exist" hint actually holds across engines. + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("opencode: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func buildPrompt(prompt string, opts map[string]any) (string, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "", fmt.Errorf("opencode: empty prompt") + } + systemPrompt := stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + systemPrompt = override + } + if systemPrompt == "" { + return prompt, nil + } + return systemPrompt + "\n\n" + prompt, nil +} + +func buildEnv(opts map[string]any) ([]string, error) { + env := []string{ + "DISABLE_TELEMETRY=1", + } + raw, ok := opts["env"] + if !ok || raw == nil { + return env, nil + } + envMap, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) + } + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envMap[k].(string) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func writeConfigHome(runID, raw string) (string, func(), error) { + if strings.TrimSpace(runID) == "" { + return "", func() {}, fmt.Errorf("opencode: runID required for scratch config") + } + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return "", func() {}, fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) + } + root, err := paths.Root() + if err != nil { + return "", func() {}, err + } + scratchRoot := filepath.Join(root, "daemon", "scratch", safeRunID(runID)) + configHome := filepath.Join(scratchRoot, "config-home") + opencodeDir := filepath.Join(configHome, "opencode") + if err := os.MkdirAll(opencodeDir, 0o700); err != nil { + return "", func() {}, fmt.Errorf("opencode: create config dir %s: %w", opencodeDir, err) + } + configPath := filepath.Join(opencodeDir, "opencode.json") + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + return "", func() {}, fmt.Errorf("opencode: write %s: %w", configPath, err) + } + cleanup := func() { _ = os.RemoveAll(scratchRoot) } + return configHome, cleanup, nil +} + +func safeRunID(runID string) string { + var b strings.Builder + for _, r := range runID { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "" { + return "run" + } + return out +} + +func firstString(opts map[string]any, keys ...string) string { + for _, key := range keys { + if v := stringOpt(opts, key); v != "" { + return v + } + } + return "" +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +func boolOpt(opts map[string]any, key string) bool { + if opts == nil { + return false + } + v, ok := opts[key] + if !ok || v == nil { + return false + } + b, ok := v.(bool) + return ok && b +} diff --git a/apps/daemon/internal/agent/opencode/options_test.go b/apps/daemon/internal/agent/opencode/options_test.go new file mode 100644 index 000000000..3e1ecec29 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/options_test.go @@ -0,0 +1,161 @@ +package opencode_test + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" +) + +func TestBuildArgsUsesOpenCodeRunJSONAndWorkdir(t *testing.T) { + res, err := opencode.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ + "model_selector": "anthropic/claude-opus-4-7", + "agent": "build", + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--format", "json") || !slices.Contains(res.Args, "run") { + t.Fatalf("args missing run/json: %v", res.Args) + } + if !containsPair(res.Args, "--dir", os.TempDir()) { + t.Fatalf("args missing --dir temp: %v", res.Args) + } + if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { + t.Fatalf("args missing model selector: %v", res.Args) + } + if !containsPair(res.Args, "--agent", "build") { + t.Fatalf("args missing agent: %v", res.Args) + } + if got := res.Args[len(res.Args)-1]; got != "hello" { + t.Fatalf("last arg prompt = %q, want hello; args=%v", got, res.Args) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + _, err := opencode.BuildArgs("run-1", "hello", "./relative", nil) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +// TestBuildArgsCreatesMissingWorkdir: align with claudecode + codex — +// a non-existent absolute path is mkdir -p'd. Pinning this keeps the +// wizard's "Created if it does not exist" hint honest across engines. +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := opencode.BuildArgs("run-1", "hello", target, nil) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--dir", target) { + t.Fatalf("args missing --dir %s: %v", target, res.Args) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildArgsWritesManagedConfigUnderOpenAgentCoreHome(t *testing.T) { + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + res, err := opencode.BuildArgs("run/id", "hello", "", map[string]any{ + "opencode_json": `{"provider":{},"permission":{"*":"allow"}}`, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + configHome := envValue(res.Env, "XDG_CONFIG_HOME") + if configHome == "" { + t.Fatalf("XDG_CONFIG_HOME missing in env: %v", res.Env) + } + wantPrefix := filepath.Join(home, "daemon", "scratch", "run_id", "config-home") + if configHome != wantPrefix { + t.Fatalf("configHome = %q, want %q", configHome, wantPrefix) + } + configPath := filepath.Join(configHome, "opencode", "opencode.json") + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected opencode.json at %s: %v", configPath, err) + } + res.Cleanup() + if _, err := os.Stat(filepath.Join(home, "daemon", "scratch", "run_id")); !os.IsNotExist(err) { + t.Fatalf("scratch dir still exists after cleanup: %v", err) + } +} + +func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ + "opencode_json": `{"provider":{}}`, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, + "docs": map[string]any{ + "url": "https://docs.example.com/mcp", + "headers": map[string]any{"Authorization": "Bearer token"}, + }, + }, + }) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(body, &config); err != nil { + t.Fatal(err) + } + mcp := config["mcp"].(map[string]any) + remote := mcp["docs"].(map[string]any) + if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { + t.Fatalf("remote = %+v", remote) + } + headers := remote["headers"].(map[string]any) + if headers["Authorization"] != "Bearer token" { + t.Fatalf("headers = %+v", headers) + } + local := mcp["local"].(map[string]any) + if local["type"] != "local" { + t.Fatalf("local = %+v", local) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if strings.HasPrefix(item, prefix) { + return strings.TrimPrefix(item, prefix) + } + } + return "" +} diff --git a/apps/daemon/internal/agent/opencode/parser.go b/apps/daemon/internal/agent/opencode/parser.go new file mode 100644 index 000000000..f18081c3d --- /dev/null +++ b/apps/daemon/internal/agent/opencode/parser.go @@ -0,0 +1,296 @@ +package opencode + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type translator struct { + runID string + seq atomic.Uint64 + + deltaBuf strings.Builder + plainBuf strings.Builder + rawLines []string + usage proto.Usage + stepUsage usageInfo + toolsSeen map[string]bool +} + +type translation struct { + Envelopes []proto.Envelope +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + t.rawLines = append(t.rawLines, string(line)) + + var head struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` + Part json.RawMessage `json:"part"` + } + if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { + if t.plainBuf.Len() > 0 { + t.plainBuf.WriteByte('\n') + } + t.plainBuf.Write(line) + return translation{}, nil + } + + switch head.Type { + case "message.part.delta": + return t.translatePartDelta(head.Properties) + case "text": + return t.translateTextPart(head.Part) + case "tool_use": + return t.translateToolPart(head.Part) + case "message.updated", "message.updated.1": + t.captureUsage(head.Properties) + return translation{}, nil + case "step_finish": + t.capturePartUsage(head.Part) + return translation{}, nil + default: + t.captureGenericUsage(line) + return translation{}, nil + } +} + +func (t *translator) translateToolPart(raw json.RawMessage) (translation, error) { + var p struct { + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State struct { + Status string `json:"status"` + Input map[string]any `json:"input"` + Output string `json:"output"` + Error string `json:"error"` + } `json:"state"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse tool part: %w", err) + } + if p.Type != "tool" || p.CallID == "" || p.Tool == "" || + (p.State.Status != "completed" && p.State.Status != "error") || t.toolsSeen[p.CallID] { + return translation{}, nil + } + result := map[string]any{"output": p.State.Output, "is_error": p.State.Status == "error"} + if p.State.Status == "error" { + result["output"] = p.State.Error + } + // JSON CLI tool parts arrive only after execution. Pair the call and + // result for existing trace consumers; neither frame requests approval. + call := proto.ToolCallPayload{ID: p.CallID, Name: p.Tool, Stage: "before", Args: p.State.Input} + before, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) + if err != nil { + return translation{}, err + } + call.Stage, call.Result = "after", result + after, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) + if err != nil { + return translation{}, err + } + if t.toolsSeen == nil { + t.toolsSeen = make(map[string]bool) + } + t.toolsSeen[p.CallID] = true + return translation{Envelopes: []proto.Envelope{before, after}}, nil +} + +func (t *translator) translatePartDelta(raw json.RawMessage) (translation, error) { + var p struct { + Field string `json:"field"` + Delta string `json:"delta"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse message.part.delta: %w", err) + } + if p.Delta == "" || (p.Field != "" && p.Field != "text") { + return translation{}, nil + } + t.deltaBuf.WriteString(p.Delta) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Delta, Sequence: t.seq.Add(1)}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateTextPart(raw json.RawMessage) (translation, error) { + var p struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return translation{}, fmt.Errorf("opencode: parse text part: %w", err) + } + if p.Text == "" || (p.Type != "" && p.Type != "text") { + return translation{}, nil + } + t.deltaBuf.WriteString(p.Text) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Text, Sequence: t.seq.Add(1)}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) captureUsage(raw json.RawMessage) { + var p struct { + Info usageInfo `json:"info"` + } + if err := json.Unmarshal(raw, &p); err == nil { + t.mergeUsage(p.Info) + } +} + +func (t *translator) capturePartUsage(raw json.RawMessage) { + var p usageInfo + if err := json.Unmarshal(raw, &p); err == nil { + if p.Tokens.CacheRead == 0 { + p.Tokens.CacheRead = p.Tokens.Cache.Read + } + if p.Tokens.CacheWrite == 0 { + p.Tokens.CacheWrite = p.Tokens.Cache.Write + } + t.stepUsage.Tokens.Input += p.Tokens.Input + t.stepUsage.Tokens.Output += p.Tokens.Output + t.stepUsage.Tokens.Reasoning += p.Tokens.Reasoning + t.stepUsage.Tokens.CacheRead += p.Tokens.CacheRead + t.stepUsage.Tokens.CacheWrite += p.Tokens.CacheWrite + t.stepUsage.Tokens.Total += p.Tokens.Total + t.stepUsage.Cost += p.Cost + t.mergeUsage(t.stepUsage) + } +} + +func (t *translator) captureGenericUsage(raw json.RawMessage) { + var p struct { + Info usageInfo `json:"info"` + Tokens usageTokens `json:"tokens"` + Cost float64 `json:"cost"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return + } + t.mergeUsage(p.Info) + if p.Tokens.Input != 0 || p.Tokens.Output != 0 || p.Cost != 0 { + t.mergeUsage(usageInfo{Tokens: p.Tokens, Cost: p.Cost}) + } +} + +type usageInfo struct { + Tokens usageTokens `json:"tokens"` + Cost float64 `json:"cost"` +} + +type usageTokens struct { + Input int32 `json:"input"` + Output int32 `json:"output"` + Reasoning int32 `json:"reasoning"` + CacheRead int32 `json:"cacheRead"` + CacheWrite int32 `json:"cacheWrite"` + Total int32 `json:"total"` + Cache struct { + Read int32 `json:"read"` + Write int32 `json:"write"` + } `json:"cache"` +} + +func (t *translator) mergeUsage(info usageInfo) { + if info.Tokens.CacheRead == 0 { + info.Tokens.CacheRead = info.Tokens.Cache.Read + } + if info.Tokens.CacheWrite == 0 { + info.Tokens.CacheWrite = info.Tokens.Cache.Write + } + if info.Tokens.Input != 0 { + t.usage.InputTokens = info.Tokens.Input + } + if info.Tokens.Output != 0 { + t.usage.OutputTokens = info.Tokens.Output + } + if info.Cost != 0 { + t.usage.CostUSD = info.Cost + } + if info.Tokens.Reasoning != 0 || info.Tokens.CacheRead != 0 || info.Tokens.CacheWrite != 0 || info.Tokens.Total != 0 { + if t.usage.Raw == nil { + t.usage.Raw = map[string]any{} + } + if info.Tokens.Reasoning != 0 { + t.usage.Raw["reasoning_tokens"] = info.Tokens.Reasoning + } + if info.Tokens.CacheRead != 0 { + t.usage.Raw["cache_read_tokens"] = info.Tokens.CacheRead + } + if info.Tokens.CacheWrite != 0 { + t.usage.Raw["cache_write_tokens"] = info.Tokens.CacheWrite + } + if info.Tokens.Total != 0 { + t.usage.Raw["total_tokens"] = info.Tokens.Total + } + } +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + if t.plainBuf.Len() > 0 && t.deltaBuf.Len() == 0 { + delta := strings.TrimSpace(t.plainBuf.String()) + if delta != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: delta, Sequence: t.seq.Add(1)}); err == nil { + envs = append(envs, env) + } + t.deltaBuf.WriteString(delta) + } + } + usage := t.usage + usage.Provider = "opencode" + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { + if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { + envs = append(envs, env) + } + } + if waitErr != nil || cancelled { + msg := "opencode: subprocess exited without success" + if waitErr != nil { + msg = fmt.Sprintf("opencode: subprocess exited: %v", waitErr) + } + if strings.TrimSpace(stderr) != "" { + msg += ": " + truncate(strings.TrimSpace(stderr), 400) + } + if cancelled { + msg = "opencode: cancelled" + } + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { + envs = append(envs, env) + } + } + content := strings.TrimSpace(t.deltaBuf.String()) + metadata := map[string]any{"connector_path": "opencode_run"} + if len(t.rawLines) > 0 { + metadata["opencode_raw_lines"] = t.rawLines + } + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{Content: content, Transcript: strings.Join(t.rawLines, "\n"), Usage: usage, Metadata: metadata}); err == nil { + envs = append(envs, env) + } + return envs +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/daemon/internal/agent/opencode/parser_test.go b/apps/daemon/internal/agent/opencode/parser_test.go new file mode 100644 index 000000000..8242fd865 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/parser_test.go @@ -0,0 +1,169 @@ +package opencode_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestTranslatePartDeltaEmitsDeltaAndDone(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message.part.delta","properties":{"field":"text","delta":"hello"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "hello" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } + envs := tr.TerminalEnvelopes(nil, "", false) + last := envs[len(envs)-1] + if last.Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done", last.Type) + } + done := decodePayload[proto.DonePayload](t, last) + if done.Content != "hello" || done.Metadata["connector_path"] != "opencode_run" { + t.Fatalf("done payload = %#v", done) + } +} + +func TestTranslateTextEventsEmitDeltasAndDone(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-text") + tx, err := tr.Translate([]byte(`{"type":"text","timestamp":1785838824775,"sessionID":"ses_1","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_1","type":"text","text":"OK"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "OK" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } + if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_2","type":"step-finish"}}`)); err != nil { + t.Fatalf("Translate step finish: %v", err) + } + tx, err = tr.Translate([]byte(`{"type":"text","timestamp":1785838824776,"sessionID":"ses_1","part":{"id":"prt_3","messageID":"msg_1","sessionID":"ses_1","type":"text","text":" again"}}`)) + if err != nil { + t.Fatalf("Translate second text: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("second delta envelopes = %#v", tx.Envelopes) + } + second := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if second.Delta != " again" || second.Sequence <= delta.Sequence { + t.Fatalf("second delta payload = %#v", second) + } + if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_4","type":"step-finish"}}`)); err != nil { + t.Fatalf("Translate second step finish: %v", err) + } + + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "OK again" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestTranslateCapturesUsage(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-u") + _, err := tr.Translate([]byte(`{"type":"message.updated","properties":{"info":{"cost":0.25,"tokens":{"input":10,"output":7,"reasoning":3,"cacheRead":2,"cacheWrite":1,"total":23}}}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.Provider != "opencode" || got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.25 { + t.Fatalf("usage = %#v", got) + } + if got.Raw["total_tokens"] != float64(23) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +func TestTranslateStepFinishCapturesUsage(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-step-finish") + _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":12576,"input":11803,"output":645,"reasoning":0,"cache":{"write":4,"read":128}},"cost":0.00432258}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":25,"input":20,"output":3,"reasoning":2,"cache":{"write":1,"read":4}},"cost":0.001}}`)) + if err != nil { + t.Fatalf("Translate second step: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.InputTokens != 11823 || got.OutputTokens != 648 || got.CostUSD != 0.00532258 { + t.Fatalf("usage = %#v", got) + } + if got.Raw["total_tokens"] != float64(12601) || got.Raw["reasoning_tokens"] != float64(2) || got.Raw["cache_read_tokens"] != float64(132) || got.Raw["cache_write_tokens"] != float64(5) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +func TestPlainOutputFallsBackToDelta(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-p") + _, _ = tr.Translate([]byte("plain output")) + envs := tr.TerminalEnvelopes(nil, "", false) + if len(envs) < 2 || envs[0].Type != proto.TypeDelta || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("plain terminal envs = %#v", envs) + } + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "plain output" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestTerminalErrorIncludesStderr(t *testing.T) { + tr := opencode.NewTranslatorForTest("run-e") + envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) + if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("error terminal envs = %#v", envs) + } + errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) + if errPayload.Error == "" || !contains(errPayload.Error, "bad auth") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return sub == "" +} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go b/apps/daemon/internal/agent/opencode/parser_tools_test.go similarity index 96% rename from apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go rename to apps/daemon/internal/agent/opencode/parser_tools_test.go index 6a5dbacd6..b9492c719 100644 --- a/apps/parsar-daemon/internal/agent/opencode/parser_tools_test.go +++ b/apps/daemon/internal/agent/opencode/parser_tools_test.go @@ -4,8 +4,8 @@ import ( "fmt" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestTranslateCompletedToolsPreservesTraceAndReply(t *testing.T) { diff --git a/apps/daemon/internal/agent/opencode/session.go b/apps/daemon/internal/agent/opencode/session.go new file mode 100644 index 000000000..162e88293 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/session.go @@ -0,0 +1,221 @@ +// Package opencode is the agent_kind="opencode" adapter. It drives the +// OpenCode CLI via `opencode run --format json`. +package opencode + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +type sessionConfig struct { + opencodeBinary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{opencodeBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="opencode". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `opencode run` subprocess. +type Session struct { + runID string + model string + cfg sessionConfig + + cmd *exec.Cmd + out chan<- proto.Envelope + + cancelCtx context.Context + cancelFn context.CancelFunc + + cancelOnce sync.Once + closeOutOnce sync.Once + waitDone chan struct{} + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("opencode: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.opencodeBinary == "" { + cfg.opencodeBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + opts, err := prepareManagedSkills(parent, cfg.logger, req) + if err != nil { + return nil, err + } + + prompt, err := req.Input.TextOnly() + if err != nil { + return nil, err + } + buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts) + if err != nil { + return nil, fmt.Errorf("opencode: build args: %w", err) + } + cancelCtx, cancelFn := context.WithCancel(parent) + + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + cmd := exec.CommandContext(cancelCtx, cfg.opencodeBinary, args...) + if buildRes.WorkDir != "" { + cmd.Dir = buildRes.WorkDir + } + cmd.Env = append(os.Environ(), buildRes.Env...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + cancelFn() + buildRes.Cleanup() + return nil, fmt.Errorf("opencode: start %q: %w", cfg.opencodeBinary, err) + } + model := buildRes.ModelSelector + if _, key, qualified := strings.Cut(model, "/"); qualified { + model = key + } + + s := &Session{ + runID: req.RunID, + model: model, + cfg: cfg, + cmd: cmd, + out: out, + cancelCtx: cancelCtx, + cancelFn: cancelFn, + waitDone: make(chan struct{}), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(stderr) + go s.run(stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + if s.cmd.Process == nil { + return + } + _ = s.cmd.Process.Signal(syscall.SIGTERM) + go func() { + select { + case <-s.waitDone: + return + case <-time.After(s.cfg.killTimeout): + _ = s.cmd.Process.Signal(syscall.SIGKILL) + } + }() + s.cancelFn() + }) + return nil +} + +func (s *Session) run(stdout io.Reader) { + defer close(s.waitDone) + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + tr.usage.Model = s.model + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tx, err := tr.Translate(sc.Bytes()) + if err != nil { + s.cfg.logger.Warn("opencode: translate line", "run_id", s.runID, "err", err) + continue + } + for _, env := range tx.Envelopes { + select { + case s.out <- env: + case <-s.cancelCtx.Done(): + _ = s.cmd.Wait() + return + } + } + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("opencode: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.cmd.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("opencode stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("opencode: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/opencode/session_model_test.go b/apps/daemon/internal/agent/opencode/session_model_test.go similarity index 92% rename from apps/parsar-daemon/internal/agent/opencode/session_model_test.go rename to apps/daemon/internal/agent/opencode/session_model_test.go index 534ec0c98..2db480ea1 100644 --- a/apps/parsar-daemon/internal/agent/opencode/session_model_test.go +++ b/apps/daemon/internal/agent/opencode/session_model_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSessionUsageCarriesSelectedModel(t *testing.T) { diff --git a/apps/daemon/internal/agent/opencode/session_test.go b/apps/daemon/internal/agent/opencode/session_test.go new file mode 100644 index 000000000..7ba1acaf9 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/session_test.go @@ -0,0 +1,406 @@ +package opencode_test + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `opencode` when +// OPENCODE_TESTHELPER_ROLE is set, bypassing m.Run so the test +// framework's PASS line doesn't pollute fake stdout. +const opencodeHelperEnvKey = "OPENCODE_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(opencodeHelperEnvKey); role != "" { + runFakeOpenCode(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakeOpenCode(role string) { + if dumpPath := os.Getenv("OPENCODE_TESTHELPER_CONFIG_DUMP"); dumpPath != "" { + body, err := json.Marshal(map[string]string{ + "config_dir": os.Getenv("OPENCODE_CONFIG_DIR"), + "xdg_config_home": os.Getenv("XDG_CONFIG_HOME"), + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "encode managed config env: %v\n", err) + os.Exit(65) + } + if err := os.WriteFile(dumpPath, body, 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "dump managed config: %v\n", err) + os.Exit(65) + } + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + + sawRun := false + sawJSONFormat := false + for i, arg := range os.Args[1:] { + if arg == "run" { + sawRun = true + } + if arg == "--format" && i+2 <= len(os.Args[1:]) && os.Args[i+2] == "json" { + sawJSONFormat = true + } + } + + switch role { + case "json-success": + if !sawRun || !sawJSONFormat { + _, _ = os.Stderr.WriteString("missing opencode run --format json\n") + os.Exit(64) + } + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "hi ", + }, + }) + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "there", + }, + }) + _ = enc.Encode(map[string]any{ + "type": "message.updated", + "properties": map[string]any{ + "info": map[string]any{ + "cost": 0.12, + "tokens": map[string]any{ + "input": 4, + "output": 2, + "total": 6, + }, + }, + }, + }) + + case "plain-success": + _, _ = os.Stdout.WriteString("plain line one\nplain line two\n") + + case "nonzero": + _, _ = os.Stderr.WriteString("bad auth from fake opencode\n") + os.Exit(17) + + case "hang": + _ = enc.Encode(map[string]any{ + "type": "message.part.delta", + "properties": map[string]any{ + "field": "text", + "delta": "started", + }, + }) + time.Sleep(10 * time.Minute) + } +} + +func opencodeHelperConfig() opencode.SessionConfigForTest { + return opencode.SessionConfigForTest{ + OpenCodeBinary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func opencodeHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Input: proto.TextInput(prompt), + AgentOptions: map[string]any{ + "env": map[string]any{ + opencodeHelperEnvKey: role, + }, + }, + } +} + +func drainOpenCode(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_json", "hello", "json-success"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeDelta) + mustContainOpenCode(t, types, proto.TypeUsage) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + for _, env := range got { + if env.ID != "run_json" { + t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) + } + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "hi there" { + t.Fatalf("done content = %q, want hi there", done.Content) + } + if done.Usage.Provider != "opencode" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { + t.Fatalf("done usage = %#v", done.Usage) + } +} + +func TestSessionInstallsAndRegistersManagedSkills(t *testing.T) { + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + t.Setenv("OPENCODE_CONFIG_DIR", "") + userConfigHome := filepath.Join(t.TempDir(), "user-config") + t.Setenv("XDG_CONFIG_HOME", userConfigHome) + body := openCodeSkillZip(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + dumpPath := filepath.Join(t.TempDir(), "opencode.json") + out := make(chan proto.Envelope, 32) + req := opencodeHelperReq("run_skills", "hello", "json-success") + req.ConversationID = "conv-skills" + req.AgentStateKey = "conv-skills/agent-1/opencode" + req.AgentOptions["skills"] = []any{map[string]any{ + "name": "find-skills", "version": "1.0.0", "download_url": srv.URL, + "sha256": fmt.Sprintf("%x", sha256.Sum256(body)), + }} + req.AgentOptions["env"].(map[string]any)["OPENCODE_TESTHELPER_CONFIG_DUMP"] = dumpPath + + sess, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainOpenCode(t, out, 5*time.Second); !closed { + t.Fatal("out did not close") + } + + skillRoot := filepath.Join(home, "runtime", "opencode", "state", "conv-skills", "agent-1", "opencode", "skills") + if _, err := os.Stat(filepath.Join(skillRoot, "find-skills", "SKILL.md")); err != nil { + t.Fatalf("managed skill missing: %v", err) + } + dumped, err := os.ReadFile(dumpPath) + if err != nil { + t.Fatalf("read dumped config: %v", err) + } + var configEnv map[string]string + if err := json.Unmarshal(dumped, &configEnv); err != nil { + t.Fatalf("decode dumped config env: %v", err) + } + if got, want := configEnv["config_dir"], filepath.Dir(skillRoot); got != want { + t.Fatalf("OPENCODE_CONFIG_DIR = %q, want %q", got, want) + } + if got := configEnv["xdg_config_home"]; got != userConfigHome { + t.Fatalf("XDG_CONFIG_HOME = %q, want inherited %q", got, userConfigHome) + } + + cleanupReq := opencodeHelperReq("run_skills_cleanup", "hello", "json-success") + cleanupReq.ConversationID = req.ConversationID + cleanupReq.AgentStateKey = req.AgentStateKey + cleanupOut := make(chan proto.Envelope, 32) + cleanupSession, err := opencode.NewSessionForTest(context.Background(), cleanupReq, cleanupOut, opencodeHelperConfig()) + if err != nil { + t.Fatalf("cleanup session: %v", err) + } + defer cleanupSession.Cancel(context.Background()) + if _, closed := drainOpenCode(t, cleanupOut, 5*time.Second); !closed { + t.Fatal("cleanup out did not close") + } + if _, err := os.Stat(filepath.Join(skillRoot, "find-skills")); !os.IsNotExist(err) { + t.Fatalf("unbound skill still exists: %v", err) + } +} + +func openCodeSkillZip(t *testing.T) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + entry, err := writer.Create("SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("---\nname: find-skills\ndescription: Find skills\n---\nUse the catalog.")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestSessionPlainStdoutFallsBackToDeltaAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_plain", "hello", "plain-success"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + if len(got) < 2 || got[0].Type != proto.TypeDelta || got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("plain envs = %v", types) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "plain line one\nplain line two" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_err", "hello", "nonzero"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeError) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "bad auth from fake opencode") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_cancel", "hello", "hang"), out, opencodeHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + // Let the helper emit its first delta before cancellation. + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainOpenCode(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := opencodeEnvTypes(got) + mustContainOpenCode(t, types, proto.TypeError) + mustContainOpenCode(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } +} + +func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { + var session any = (*opencode.Session)(nil) + if _, ok := session.(agent.PermissionResponder); ok { + t.Fatal("unexpected permission responder") + } + if _, ok := session.(agent.UserChoiceResponder); ok { + t.Fatal("unexpected user-choice responder") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_nil", "hello", "json-success"), nil, opencodeHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + out := make(chan proto.Envelope, 4) + _, err := opencode.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, opencodeHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := opencodeHelperConfig() + cfg.OpenCodeBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := opencode.NewSessionForTest(context.Background(), + opencodeHelperReq("run_bad", "hello", "json-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func opencodeEnvTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} + +func mustContainOpenCode(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Fatalf("expected %q in %v", needle, haystack) + } +} diff --git a/apps/daemon/internal/agent/opencode/skills.go b/apps/daemon/internal/agent/opencode/skills.go new file mode 100644 index 000000000..8f8d3dcf6 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/skills.go @@ -0,0 +1,134 @@ +package opencode + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +const ( + openCodeConfigDirEnv = "OPENCODE_CONFIG_DIR" + openCodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" +) + +func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (map[string]any, error) { + rawSkills, hasSkills := req.AgentOptions["skills"] + if !hasSkills && strings.TrimSpace(req.AgentStateKey) == "" && strings.TrimSpace(req.ConversationID) == "" && strings.TrimSpace(req.RunID) == "" { + return req.AgentOptions, nil + } + root, err := agent.ManagedSkillsRoot("opencode", req.AgentStateKey, req.ConversationID, req.RunID) + if err != nil { + return nil, fmt.Errorf("opencode: resolve managed skills root: %w", err) + } + result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) + if err != nil { + return nil, fmt.Errorf("opencode: install skills: %w", err) + } + for _, warning := range result.Warnings { + logger.Warn("opencode: skill install warning", "run_id", req.RunID, "msg", warning) + } + if len(result.SkillDirs) == 0 { + return req.AgentOptions, nil + } + return withOpenCodeSkillRoot(req.AgentOptions, root) +} + +func withOpenCodeSkillRoot(opts map[string]any, root string) (map[string]any, error) { + out := make(map[string]any, len(opts)+1) + for key, value := range opts { + out[key] = value + } + env := map[string]any{} + if raw := opts["env"]; raw != nil { + existing, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) + } + for key, value := range existing { + env[key] = value + } + } + configDir, err := openCodeEnvValue(env, openCodeConfigDirEnv) + if err != nil { + return nil, err + } + if strings.TrimSpace(configDir) == "" { + env[openCodeConfigDirEnv] = filepath.Dir(root) + } else { + inline, err := openCodeEnvValue(env, openCodeConfigContentEnv) + if err != nil { + return nil, err + } + inline, err = addOpenCodeSkillPath(inline, root) + if err != nil { + return nil, err + } + env[openCodeConfigContentEnv] = inline + } + out["env"] = env + return out, nil +} + +func openCodeEnvValue(env map[string]any, key string) (string, error) { + if raw, ok := env[key]; ok { + value, ok := raw.(string) + if !ok { + return "", fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", key, raw) + } + return value, nil + } + return os.Getenv(key), nil +} + +func addOpenCodeSkillPath(rawConfig, root string) (string, error) { + config := map[string]any{} + if strings.TrimSpace(rawConfig) != "" { + if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { + return "", fmt.Errorf("opencode: %s must be valid JSON: %w", openCodeConfigContentEnv, err) + } + if config == nil { + config = map[string]any{} + } + } + skills, ok := config["skills"].(map[string]any) + if !ok && config["skills"] != nil { + return "", fmt.Errorf("opencode: %s skills must be object, got %T", openCodeConfigContentEnv, config["skills"]) + } + if skills == nil { + skills = map[string]any{} + } + paths := []any{} + if rawPaths := skills["paths"]; rawPaths != nil { + var ok bool + paths, ok = rawPaths.([]any) + if !ok { + return "", fmt.Errorf("opencode: %s skills.paths must be array, got %T", openCodeConfigContentEnv, rawPaths) + } + } + found := false + for _, path := range paths { + value, ok := path.(string) + if !ok { + return "", fmt.Errorf("opencode: %s skills.paths entries must be strings", openCodeConfigContentEnv) + } + found = found || value == root + } + if found { + return rawConfig, nil + } + skills["paths"] = append(paths, root) + config["skills"] = skills + body, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("opencode: marshal %s: %w", openCodeConfigContentEnv, err) + } + return string(body), nil +} diff --git a/apps/parsar-daemon/internal/agent/opencode/skills_test.go b/apps/daemon/internal/agent/opencode/skills_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/opencode/skills_test.go rename to apps/daemon/internal/agent/opencode/skills_test.go diff --git a/apps/daemon/internal/agent/opencode/version.go b/apps/daemon/internal/agent/opencode/version.go new file mode 100644 index 000000000..f815f80a2 --- /dev/null +++ b/apps/daemon/internal/agent/opencode/version.go @@ -0,0 +1,35 @@ +package opencode + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the OpenCode documentation when the +// daemon can see the adapter but not the CLI binary. +const InstallURL = "https://opencode.ai/docs" + +// defaultBinary is the executable to probe and spawn: binpath.OpenCode() +// honours the OAC_RUNTIME_OPENCODE_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.OpenCode() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary +// cannot be located on PATH. Callers use errors.Is to distinguish an +// install problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("opencode CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "opencode", + DefaultBinary: defaultBinary(), + MissingError: ErrCLINotFound, + TrimBinary: true, + }) +} diff --git a/apps/daemon/internal/agent/opencode/version_test.go b/apps/daemon/internal/agent/opencode/version_test.go new file mode 100644 index 000000000..bdc3c531f --- /dev/null +++ b/apps/daemon/internal/agent/opencode/version_test.go @@ -0,0 +1,18 @@ +package opencode_test + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "opencode", + DefaultBinary: "opencode", + MissingError: opencode.ErrCLINotFound, + Check: opencode.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} diff --git a/apps/daemon/internal/agent/pi/export_test.go b/apps/daemon/internal/agent/pi/export_test.go new file mode 100644 index 000000000..5bae4f52e --- /dev/null +++ b/apps/daemon/internal/agent/pi/export_test.go @@ -0,0 +1,36 @@ +package pi + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + PiBinary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + piBinary: cfg.PiBinary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +type Translation = translation + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) Translate(line []byte) (Translation, error) { + return (*translator)(t).Translate(line) +} + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} diff --git a/apps/parsar-daemon/internal/agent/pi/options.go b/apps/daemon/internal/agent/pi/options.go similarity index 100% rename from apps/parsar-daemon/internal/agent/pi/options.go rename to apps/daemon/internal/agent/pi/options.go diff --git a/apps/daemon/internal/agent/pi/options_test.go b/apps/daemon/internal/agent/pi/options_test.go new file mode 100644 index 000000000..94829de58 --- /dev/null +++ b/apps/daemon/internal/agent/pi/options_test.go @@ -0,0 +1,240 @@ +package pi_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" +) + +func TestBuildArgsUsesModeJsonAndPromptLast(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ + "model": "anthropic/claude-opus-4-7", + "api_key": "sk-test", + "provider": "anthropic", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + if !containsPair(res.Args, "--mode", "json") { + t.Fatalf("args missing --mode json: %v", res.Args) + } + if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { + t.Fatalf("args missing --model: %v", res.Args) + } + // Secrets must never ride on argv (ps would leak them): a provided + // api_key is intentionally dropped here and delivered via env instead + // (see server injectPiManagedModel / OAC_RUNTIME_PI_API_KEY). + if slices.Contains(res.Args, "--api-key") || slices.Contains(res.Args, "sk-test") { + t.Fatalf("api_key must not leak onto argv: %v", res.Args) + } + if !containsPair(res.Args, "--provider", "anthropic") { + t.Fatalf("args missing --provider: %v", res.Args) + } + // pi's -p consumes the immediately-following arg as the prompt, so the + // prompt MUST be the final arg, preceded by -p. + n := len(res.Args) + if n < 2 || res.Args[n-2] != "-p" || res.Args[n-1] != "hello" { + t.Fatalf("expected args to end with -p hello, got %v", res.Args) + } + if res.WorkDir != os.TempDir() { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "./relative", nil, "") + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := pi.BuildArgs("run-1", "hello", target, nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if res.WorkDir != target { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, target) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + if !info.IsDir() { + t.Fatalf("target %q is not a directory", target) + } +} + +func TestBuildArgsSystemPromptAppends(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "system_prompt": "be terse", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--append-system-prompt", "be terse") { + t.Fatalf("args missing --append-system-prompt: %v", res.Args) + } + if slices.Contains(res.Args, "--system-prompt") { + t.Fatalf("system_prompt must map to append, not override: %v", res.Args) + } +} + +func TestBuildArgsOverrideSystemPromptReplaces(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are root", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--system-prompt", "you are root") { + t.Fatalf("args missing --system-prompt override: %v", res.Args) + } + // Override wins: the append we tentatively added must be stripped. + if slices.Contains(res.Args, "--append-system-prompt") { + t.Fatalf("override must strip the append: %v", res.Args) + } +} + +func TestBuildArgsResumeSessionUsesExplicitID(t *testing.T) { + sessionDir := filepath.Join(t.TempDir(), "sessions") + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "resume_session_id": "from-opts", + "session_dir": sessionDir, + }, "from-param") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--session-dir", sessionDir) { + t.Fatalf("args missing --session-dir: %v", res.Args) + } + if !containsPair(res.Args, "--session", "from-param") { + t.Fatalf("explicit resume id must win: %v", res.Args) + } +} + +func TestBuildArgsSessionDirCreatesAndAddsFlag(t *testing.T) { + sessionDir := filepath.Join(t.TempDir(), "missing", "sessions") + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "session_dir": sessionDir, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--session-dir", sessionDir) { + t.Fatalf("args missing --session-dir: %v", res.Args) + } + info, err := os.Stat(sessionDir) + if err != nil { + t.Fatalf("stat session_dir: %v", err) + } + if !info.IsDir() { + t.Fatalf("session_dir %q is not a directory", sessionDir) + } +} + +func TestBuildArgsRejectsRelativeSessionDir(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"session_dir": "./sessions"}, "") + if err == nil || !strings.Contains(err.Error(), "session_dir") { + t.Fatalf("BuildArgs session_dir err = %v, want session_dir error", err) + } +} + +func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "resume_session_id": "sess-42", + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "--session") || slices.Contains(res.Args, "sess-42") { + t.Fatalf("resume_session_id option must be ignored: %v", res.Args) + } +} + +func TestBuildArgsSkillDirsRepeatFlag(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "skill_dirs": []any{"/skills/a", "/skills/b"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if !containsPair(res.Args, "--skill", "/skills/a") { + t.Fatalf("args missing first --skill: %v", res.Args) + } + if !containsPair(res.Args, "--skill", "/skills/b") { + t.Fatalf("args missing second --skill: %v", res.Args) + } +} + +func TestBuildArgsTelemetryOptOutEnv(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", nil, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if envValue(res.Env, "PI_TELEMETRY") != "0" { + t.Fatalf("expected PI_TELEMETRY=0 opt-out, env=%v", res.Env) + } +} + +func TestBuildArgsPassesThroughEnvSorted(t *testing.T) { + res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ + "env": map[string]any{"BBB": "2", "AAA": "1"}, + }, "") + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if envValue(res.Env, "AAA") != "1" || envValue(res.Env, "BBB") != "2" { + t.Fatalf("env passthrough missing: %v", res.Env) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}, "") + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { + _, err := pi.BuildArgs("run-1", " ", "", nil, "") + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if v, ok := strings.CutPrefix(item, prefix); ok { + return v + } + } + return "" +} diff --git a/apps/daemon/internal/agent/pi/parser.go b/apps/daemon/internal/agent/pi/parser.go new file mode 100644 index 000000000..f25064ad4 --- /dev/null +++ b/apps/daemon/internal/agent/pi/parser.go @@ -0,0 +1,337 @@ +package pi + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// translator converts one NDJSON line from pi's `--mode json` stdout +// into zero or more proto.Envelope frames. One translator lives per +// session. pi has no explicit terminal frame: the stream ends at process +// EOF, so the session pump calls terminalEnvelopes after cmd.Wait. +type translator struct { + runID string + seq atomic.Uint64 + + deltaBuf strings.Builder + finalText string + rawLines []string + usage proto.Usage + usageSet bool + sessionID string + failed bool +} + +type translation struct { + Envelopes []proto.Envelope + // SessionID is surfaced from the session header line so session.go + // can write it into binding metadata for --session resume. + SessionID string +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) Translate(line []byte) (translation, error) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + return translation{}, nil + } + t.rawLines = append(t.rawLines, string(line)) + + var head struct { + Type string `json:"type"` + } + // pi emits strict JSON per line; a non-JSON line is a stray log, not + // a fatal error — skip it to keep one bad line from killing the run. + if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { + return translation{}, nil + } + + switch head.Type { + case "session": + return t.translateSessionHeader(line) + case "message_update": + return t.translateMessageUpdate(line) + case "tool_execution_start": + return t.translateToolStart(line) + case "tool_execution_end": + return t.translateToolEnd(line) + case "message_end": + return t.translateMessageEnd(line) + default: + return translation{}, nil + } +} + +func (t *translator) translateSessionHeader(line []byte) (translation, error) { + var msg struct { + ID string `json:"id"` + } + _ = json.Unmarshal(line, &msg) + if msg.ID != "" { + t.sessionID = msg.ID + } + return translation{SessionID: msg.ID}, nil +} + +func (t *translator) translateMessageUpdate(line []byte) (translation, error) { + var msg struct { + Event struct { + Type string `json:"type"` + Delta string `json:"delta"` + } `json:"assistantMessageEvent"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse message_update: %w", err) + } + switch msg.Event.Type { + case "text_delta": + if msg.Event.Delta == "" { + return translation{}, nil + } + t.deltaBuf.WriteString(msg.Event.Delta) + env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: msg.Event.Delta, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + case "thinking_delta": + if msg.Event.Delta == "" { + return translation{}, nil + } + env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ + Text: msg.Event.Delta, + Sequence: t.seq.Add(1), + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + default: + return translation{}, nil + } +} + +func (t *translator) translateToolStart(line []byte) (translation, error) { + var msg struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Args map[string]any `json:"args"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse tool_execution_start: %w", err) + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: msg.ToolCallID, + Name: msg.ToolName, + Stage: "before", + Args: msg.Args, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +func (t *translator) translateToolEnd(line []byte) (translation, error) { + var msg struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Result any `json:"result"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse tool_execution_end: %w", err) + } + env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ + ID: msg.ToolCallID, + Name: msg.ToolName, + Stage: "after", + Result: map[string]any{ + "content": msg.Result, + "is_error": msg.IsError, + }, + }) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil +} + +type piUsage struct { + Input int32 `json:"input"` + Output int32 `json:"output"` + CacheRead int32 `json:"cacheRead"` + CacheWrite int32 `json:"cacheWrite"` + CacheWrite1h int32 `json:"cacheWrite1h"` + Reasoning int32 `json:"reasoning"` + TotalTokens int32 `json:"totalTokens"` + Cost struct { + Total float64 `json:"total"` + } `json:"cost"` +} + +func (t *translator) translateMessageEnd(line []byte) (translation, error) { + var msg struct { + Message struct { + Role string `json:"role"` + Content []json.RawMessage + Provider string `json:"provider"` + Model string `json:"model"` + Usage piUsage `json:"usage"` + StopReason string `json:"stopReason"` + ErrorMessage string `json:"errorMessage"` + } `json:"message"` + } + if err := json.Unmarshal(line, &msg); err != nil { + return translation{}, fmt.Errorf("pi: parse message_end: %w", err) + } + if msg.Message.Role != "assistant" { + return translation{}, nil + } + + t.mergeUsage(msg.Message.Usage, msg.Message.Provider, msg.Message.Model) + if text := extractText(msg.Message.Content); text != "" { + t.finalText = text + } + + if msg.Message.StopReason == "error" { + t.failed = true + errMsg := msg.Message.ErrorMessage + if errMsg == "" { + errMsg = "pi: assistant message ended with error" + } + env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) + if err != nil { + return translation{}, err + } + return translation{Envelopes: []proto.Envelope{env}}, nil + } + return translation{}, nil +} + +func extractText(content []json.RawMessage) string { + var b strings.Builder + for _, raw := range content { + var item struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + if item.Type == "text" { + b.WriteString(item.Text) + } + } + return b.String() +} + +func (t *translator) mergeUsage(u piUsage, provider, model string) { + t.usageSet = true + t.usage.InputTokens += u.Input + t.usage.OutputTokens += u.Output + t.usage.CostUSD += u.Cost.Total + if provider != "" { + t.usage.Provider = provider + } + if model != "" { + t.usage.Model = model + } + // pi reports usage per assistant message; a tool loop yields several + // message_end frames, so accumulate cache/reasoning/total the same way as + // input/output above — overwriting would leave Raw showing only the last + // frame while the summed token counts reflect all of them. + t.addRawTokens("cache_read_tokens", u.CacheRead) + t.addRawTokens("cache_write_tokens", u.CacheWrite) + t.addRawTokens("cache_write_1h_tokens", u.CacheWrite1h) + t.addRawTokens("reasoning_tokens", u.Reasoning) + t.addRawTokens("total_tokens", u.TotalTokens) +} + +// addRawTokens sums one counter into usage.Raw. In-process the values stay +// int32 (matching piUsage); they only become float64 once the envelope is +// JSON-encoded downstream, which is why the lookup asserts int32. +func (t *translator) addRawTokens(key string, v int32) { + if v == 0 { + return + } + if t.usage.Raw == nil { + t.usage.Raw = map[string]any{} + } + if existing, ok := t.usage.Raw[key].(int32); ok { + v += existing + } + t.usage.Raw[key] = v +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + + content := strings.TrimSpace(t.deltaBuf.String()) + if content == "" && t.finalText != "" { + content = strings.TrimSpace(t.finalText) + if content != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: content, Sequence: t.seq.Add(1)}); err == nil { + envs = append(envs, env) + } + } + } + + usage := t.usage + if usage.Provider == "" { + usage.Provider = "pi" + } + if t.usageSet { + if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { + envs = append(envs, env) + } + } + + terminalFailed := t.failed || waitErr != nil || cancelled + if waitErr != nil || cancelled { + msg := "pi: subprocess exited without success" + if waitErr != nil { + msg = fmt.Sprintf("pi: subprocess exited: %v", waitErr) + } + if strings.TrimSpace(stderr) != "" { + msg += ": " + truncate(strings.TrimSpace(stderr), 400) + } + if cancelled { + msg = "pi: cancelled" + } + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { + envs = append(envs, env) + } + } + + metadata := map[string]any{"connector_path": "pi_print"} + if t.sessionID != "" && !terminalFailed { + metadata[proto.DoneMetaAgentSessionID] = t.sessionID + metadata[proto.DoneMetaAgentSessionType] = "pi_session" + } + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ + Content: content, + Transcript: strings.Join(t.rawLines, "\n"), + Usage: usage, + Metadata: metadata, + }); err == nil { + envs = append(envs, env) + } + return envs +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/daemon/internal/agent/pi/parser_test.go b/apps/daemon/internal/agent/pi/parser_test.go new file mode 100644 index 000000000..9bf38054d --- /dev/null +++ b/apps/daemon/internal/agent/pi/parser_test.go @@ -0,0 +1,259 @@ +package pi_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestTranslateSessionHeaderSurfacesSessionID(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if tx.SessionID != "sess-abc" { + t.Fatalf("SessionID = %q, want sess-abc", tx.SessionID) + } + if len(tx.Envelopes) != 0 { + t.Fatalf("session header should emit no envelopes, got %#v", tx.Envelopes) + } +} + +func TestTranslateTextDeltaEmitsDelta(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { + t.Fatalf("delta envelopes = %#v", tx.Envelopes) + } + delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) + if delta.Delta != "hello" || delta.Sequence == 0 { + t.Fatalf("delta payload = %#v", delta) + } +} + +func TestTranslateThinkingDeltaEmitsThinking(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"pondering"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeThinking { + t.Fatalf("thinking envelopes = %#v", tx.Envelopes) + } + think := decodePayload[proto.ThinkingPayload](t, tx.Envelopes[0]) + if think.Text != "pondering" || think.Sequence == 0 { + t.Fatalf("thinking payload = %#v", think) + } +} + +func TestTranslateToolExecutionStartEmitsBeforeToolCall(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"tool_execution_start","toolCallId":"t1","toolName":"bash","args":{"cmd":"ls"}}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { + t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) + } + tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) + if tc.ID != "t1" || tc.Name != "bash" || tc.Stage != "before" { + t.Fatalf("toolcall payload = %#v", tc) + } + if tc.Args["cmd"] != "ls" { + t.Fatalf("toolcall args = %#v", tc.Args) + } +} + +func TestTranslateToolExecutionEndEmitsAfterToolCall(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + tx, err := tr.Translate([]byte(`{"type":"tool_execution_end","toolCallId":"t1","toolName":"bash","result":{"stdout":"x"},"isError":true}`)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { + t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) + } + tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) + if tc.ID != "t1" || tc.Stage != "after" { + t.Fatalf("toolcall payload = %#v", tc) + } + if tc.Result["is_error"] != true { + t.Fatalf("toolcall result = %#v", tc.Result) + } +} + +func TestTranslateMessageEndCapturesUsage(t *testing.T) { + tr := pi.NewTranslatorForTest("run-u") + line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"provider":"anthropic","model":"claude-x","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"input":0.1,"output":0.2,"cacheRead":0,"cacheWrite":0,"total":0.3}},"stopReason":"stop"}}` + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.Provider != "anthropic" || got.Model != "claude-x" { + t.Fatalf("usage provider/model = %#v", got) + } + if got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.3 { + t.Fatalf("usage tokens/cost = %#v", got) + } + if got.Raw["cache_read_tokens"] != float64(2) { + t.Fatalf("usage raw = %#v", got.Raw) + } +} + +// A tool loop makes pi emit one message_end per assistant turn. Every +// counter — including the cache/reasoning/total ones parked in Raw — must +// sum across frames, not get clobbered by the final frame. +func TestTranslateMessageEndAccumulatesUsageAcrossFrames(t *testing.T) { + tr := pi.NewTranslatorForTest("run-multi") + first := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"a"}],"provider":"anthropic","model":"m","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"total":0.3}},"stopReason":"tool_use"}}` + second := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"provider":"anthropic","model":"m","usage":{"input":5,"output":4,"cacheRead":6,"cacheWrite":2,"reasoning":1,"totalTokens":12,"cost":{"total":0.2}},"stopReason":"stop"}}` + for _, line := range []string{first, second} { + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + } + envs := tr.TerminalEnvelopes(nil, "", false) + var got *proto.UsagePayload + for _, env := range envs { + if env.Type == proto.TypeUsage { + payload := decodePayload[proto.UsagePayload](t, env) + got = &payload + } + } + if got == nil { + t.Fatalf("usage env missing: %#v", envs) + } + if got.InputTokens != 15 || got.OutputTokens != 11 { + t.Fatalf("summed input/output = %d/%d, want 15/11", got.InputTokens, got.OutputTokens) + } + if got.CostUSD < 0.49 || got.CostUSD > 0.51 { + t.Fatalf("summed cost = %v, want ~0.5", got.CostUSD) + } + // Raw round-trips through JSON, so the counters decode back as float64. + for key, want := range map[string]float64{ + "cache_read_tokens": 8, + "cache_write_tokens": 3, + "reasoning_tokens": 4, + "total_tokens": 35, + } { + if got.Raw[key] != want { + t.Fatalf("Raw[%q] = %#v, want %v (must sum across frames)", key, got.Raw[key], want) + } + } +} + +// pi exits 0 even when the model errors: it emits a message_end whose +// assistant message carries stopReason "error". The parser MUST surface +// that as TypeError despite the clean process exit. +func TestTranslateMessageEndErrorStopReasonEmitsError(t *testing.T) { + tr := pi.NewTranslatorForTest("run-err") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-bad","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + line := `{"type":"message_end","message":{"role":"assistant","content":[],"provider":"anthropic","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"boom"}}` + tx, err := tr.Translate([]byte(line)) + if err != nil { + t.Fatalf("Translate: %v", err) + } + var found bool + for _, env := range tx.Envelopes { + if env.Type == proto.TypeError { + ep := decodePayload[proto.ErrorPayload](t, env) + if strings.Contains(ep.Error, "boom") { + found = true + } + } + } + if !found { + t.Fatalf("expected TypeError mentioning boom, got %#v", tx.Envelopes) + } + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("failed pi turn must not persist session metadata: %#v", done.Metadata) + } +} + +func TestTerminalAlwaysEmitsDoneWithSessionMetadata(t *testing.T) { + tr := pi.NewTranslatorForTest("run-1") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + if _, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)); err != nil { + t.Fatalf("Translate delta: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + last := envs[len(envs)-1] + if last.Type != proto.TypeDone { + t.Fatalf("last env = %q, want done", last.Type) + } + done := decodePayload[proto.DonePayload](t, last) + if done.Content != "hello" { + t.Fatalf("done content = %q, want hello", done.Content) + } + if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-abc" { + t.Fatalf("done metadata = %#v, want agent_session_id sess-abc", done.Metadata) + } + if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { + t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) + } +} + +func TestTerminalErrorIncludesStderr(t *testing.T) { + tr := pi.NewTranslatorForTest("run-e") + if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-failed","cwd":"/x","timestamp":"t"}`)); err != nil { + t.Fatalf("Translate header: %v", err) + } + envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) + if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("error terminal envs = %#v", envs) + } + errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) + if !strings.Contains(errPayload.Error, "bad auth") { + t.Fatalf("error payload = %#v", errPayload) + } + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("failed pi process must not persist session metadata: %#v", done.Metadata) + } +} + +func TestMessageEndContentFallbackWhenNoDeltas(t *testing.T) { + tr := pi.NewTranslatorForTest("run-f") + line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"final answer"}],"provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop"}}` + if _, err := tr.Translate([]byte(line)); err != nil { + t.Fatalf("Translate: %v", err) + } + envs := tr.TerminalEnvelopes(nil, "", false) + done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) + if done.Content != "final answer" { + t.Fatalf("done content = %q, want final answer", done.Content) + } +} + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config.go b/apps/daemon/internal/agent/pi/provider_config.go similarity index 98% rename from apps/parsar-daemon/internal/agent/pi/provider_config.go rename to apps/daemon/internal/agent/pi/provider_config.go index b17b64e6c..0d4a7f3b5 100644 --- a/apps/parsar-daemon/internal/agent/pi/provider_config.go +++ b/apps/daemon/internal/agent/pi/provider_config.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // piManagedProviderSlug belongs to the separate Parsar product integration. diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config_test.go b/apps/daemon/internal/agent/pi/provider_config_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/pi/provider_config_test.go rename to apps/daemon/internal/agent/pi/provider_config_test.go diff --git a/apps/daemon/internal/agent/pi/session.go b/apps/daemon/internal/agent/pi/session.go new file mode 100644 index 000000000..6563f2fa4 --- /dev/null +++ b/apps/daemon/internal/agent/pi/session.go @@ -0,0 +1,215 @@ +// Package pi is the agent_kind="pi" adapter. It drives the pi CLI via +// `pi --mode json -p `, translating pi's NDJSON event stream on +// stdout into proto.Envelope frames for the dispatch router. +package pi + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +type sessionConfig struct { + piBinary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{piBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="pi". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `pi --mode json` subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + out chan<- proto.Envelope + + cancelCtx context.Context + + cancelOnce sync.Once + closeOutOnce sync.Once + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("pi: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.piBinary == "" { + cfg.piBinary = defaultBinary() + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + + // pi needs an explicit --skill flag per skill (unlike Claude Code's + // auto-scan), so installSkills returns dirs even on a cache hit. + opts := req.AgentOptions + if rawSkills, ok := opts["skills"]; ok { + descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) + for _, w := range decodeWarns { + cfg.logger.Warn("pi: skill descriptor decode warning", "run_id", req.RunID, "msg", w) + } + root, rErr := resolveSkillsRoot(req.ConversationID, req.RunID) + if rErr != nil { + return nil, fmt.Errorf("pi: resolve skills root: %w", rErr) + } + installRes, iErr := installSkills(parent, cfg.logger, root, descriptors) + if iErr != nil { + return nil, fmt.Errorf("pi: install skills: %w", iErr) + } + for _, w := range installRes.Warnings { + cfg.logger.Warn("pi: skill install warning", "run_id", req.RunID, "msg", w) + } + if len(installRes.SkillDirs) > 0 { + opts = cloneAgentOptions(req.AgentOptions) + opts["skill_dirs"] = mergeSkillDirs(opts["skill_dirs"], installRes.SkillDirs) + } + } + + // Materialise pi's managed config and pin --session-dir to the stable + // conversation/agent/engine state key so --session can resolve reliably. + provOpts, provErr := applyPiRuntimeState(opts, req.AgentStateKey, req.ConversationID, req.RunID) + if provErr != nil { + return nil, fmt.Errorf("pi: apply managed provider: %w", provErr) + } + opts = provOpts + + prompt, err := req.Input.TextOnly() + if err != nil { + return nil, err + } + buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts, req.AgentSessionID) + if err != nil { + return nil, fmt.Errorf("pi: build args: %w", err) + } + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + dir := "" + if buildRes.WorkDir != "" { + dir = buildRes.WorkDir + } + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.piBinary, + Args: args, + Dir: dir, + Env: append(os.Environ(), buildRes.Env...), + KillTimeout: cfg.killTimeout, + }) + if err != nil { + buildRes.Cleanup() + return nil, fmt.Errorf("pi: start %q: %w", cfg.piBinary, err) + } + + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + out: out, + cancelCtx: proc.Context(), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + s.proc.Cancel() + }) + return nil +} + +func (s *Session) run(stdout io.Reader) { + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tx, err := tr.Translate(sc.Bytes()) + if err != nil { + s.cfg.logger.Warn("pi: translate line", "run_id", s.runID, "err", err) + continue + } + for _, env := range tx.Envelopes { + select { + case s.out <- env: + case <-s.cancelCtx.Done(): + _ = s.proc.Wait() + return + } + } + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("pi: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.proc.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("pi stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("pi: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/pi/session_provider_test.go b/apps/daemon/internal/agent/pi/session_provider_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/pi/session_provider_test.go rename to apps/daemon/internal/agent/pi/session_provider_test.go index 1500817f1..d3ce2f035 100644 --- a/apps/parsar-daemon/internal/agent/pi/session_provider_test.go +++ b/apps/daemon/internal/agent/pi/session_provider_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // TestNewSessionMaterialisesPiProviderModelsJSON proves agent_options["pi_provider"] diff --git a/apps/parsar-daemon/internal/agent/pi/session_skills_test.go b/apps/daemon/internal/agent/pi/session_skills_test.go similarity index 97% rename from apps/parsar-daemon/internal/agent/pi/session_skills_test.go rename to apps/daemon/internal/agent/pi/session_skills_test.go index 710fd9e3b..fe10a4fc3 100644 --- a/apps/parsar-daemon/internal/agent/pi/session_skills_test.go +++ b/apps/daemon/internal/agent/pi/session_skills_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // TestNewSessionInstallsSkillsAndInjectsSkillFlag is the end-to-end proof diff --git a/apps/daemon/internal/agent/pi/session_test.go b/apps/daemon/internal/agent/pi/session_test.go new file mode 100644 index 000000000..c9ad07c29 --- /dev/null +++ b/apps/daemon/internal/agent/pi/session_test.go @@ -0,0 +1,331 @@ +package pi_test + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `pi` when +// PI_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS +// line doesn't pollute fake stdout. +const piHelperEnvKey = "PI_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(piHelperEnvKey); role != "" { + runFakePi(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakePi(role string) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + + // Record argv so the skill-injection test can assert --skill + // reached the subprocess. + if argsFile := os.Getenv("PI_TESTHELPER_ARGS_FILE"); argsFile != "" { + _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o644) + } + + sawModeJSON := false + for i, arg := range os.Args { + if arg == "--mode" && i+1 < len(os.Args) && os.Args[i+1] == "json" { + sawModeJSON = true + } + } + + header := map[string]any{"type": "session", "id": "sess-xyz", "cwd": "/", "timestamp": "t"} + delta := func(s string) map[string]any { + return map[string]any{ + "type": "message_update", + "message": map[string]any{"role": "assistant"}, + "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": s}, + } + } + + switch role { + case "json-success": + if !sawModeJSON { + _, _ = os.Stderr.WriteString("missing --mode json\n") + os.Exit(64) + } + _ = enc.Encode(header) + _ = enc.Encode(delta("hi ")) + _ = enc.Encode(delta("there")) + _ = enc.Encode(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + "content": []any{map[string]any{"type": "text", "text": "hi there"}}, + "provider": "anthropic", + "model": "claude-x", + "stopReason": "stop", + "usage": map[string]any{ + "input": 4, "output": 2, "cacheRead": 0, "cacheWrite": 0, + "totalTokens": 6, + "cost": map[string]any{"input": 0.1, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.12}, + }, + }, + }) + + case "error-stop": + // pi exits 0 even when the model errors — it just emits a + // message_end with stopReason "error". + _ = enc.Encode(header) + _ = enc.Encode(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + "content": []any{}, + "provider": "anthropic", + "model": "claude-x", + "stopReason": "error", + "errorMessage": "model boom", + "usage": map[string]any{ + "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0, + "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}, + }, + }, + }) + + case "nonzero": + _, _ = os.Stderr.WriteString("bad auth from fake pi\n") + os.Exit(17) + + case "hang": + _ = enc.Encode(header) + _ = enc.Encode(delta("started")) + time.Sleep(10 * time.Minute) + } +} + +func piHelperConfig() pi.SessionConfigForTest { + return pi.SessionConfigForTest{ + PiBinary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func piHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Input: proto.TextInput(prompt), + AgentOptions: map[string]any{ + "env": map[string]any{ + piHelperEnvKey: role, + }, + }, + } +} + +func drainPi(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { + out := make(chan proto.Envelope, 32) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_json", "hello", "json-success"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeDelta) + mustContainPi(t, types, proto.TypeUsage) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + for _, env := range got { + if env.ID != "run_json" { + t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) + } + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "hi there" { + t.Fatalf("done content = %q, want hi there", done.Content) + } + if done.Usage.Provider != "anthropic" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { + t.Fatalf("done usage = %#v", done.Usage) + } + if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-xyz" { + t.Fatalf("done metadata = %#v, want agent_session_id sess-xyz", done.Metadata) + } + if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { + t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) + } +} + +// pi exits 0 on a model error: the session must still surface TypeError +// (from stopReason) and Done, and close out. +func TestSessionModelErrorStopReasonStillEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_mod_err", "hello", "error-stop"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeError) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "model boom") { + t.Fatalf("error payload = %#v, want model boom", errPayload) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("model error must not persist session metadata: %#v", done.Metadata) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_err", "hello", "nonzero"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeError) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "bad auth from fake pi") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { + out := make(chan proto.Envelope, 16) + sess, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_cancel", "hello", "hang"), out, piHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainPi(t, out, 5*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + types := piEnvTypes(got) + mustContainPi(t, types, proto.TypeDone) + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) + } +} + +func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { + var session any = (*pi.Session)(nil) + if _, ok := session.(agent.PermissionResponder); ok { + t.Fatal("unexpected permission responder") + } + if _, ok := session.(agent.UserChoiceResponder); ok { + t.Fatal("unexpected user-choice responder") + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + _, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_nil", "hello", "json-success"), nil, piHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + out := make(chan proto.Envelope, 4) + _, err := pi.NewSessionForTest(context.Background(), + proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, piHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + out := make(chan proto.Envelope, 4) + cfg := piHelperConfig() + cfg.PiBinary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := pi.NewSessionForTest(context.Background(), + piHelperReq("run_bad", "hello", "json-success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} + +func piEnvTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} + +func mustContainPi(t *testing.T, haystack []string, needle string) { + t.Helper() + if !slices.Contains(haystack, needle) { + t.Fatalf("expected %q in %v", needle, haystack) + } +} diff --git a/apps/parsar-daemon/internal/agent/pi/skills.go b/apps/daemon/internal/agent/pi/skills.go similarity index 100% rename from apps/parsar-daemon/internal/agent/pi/skills.go rename to apps/daemon/internal/agent/pi/skills.go diff --git a/apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go b/apps/daemon/internal/agent/pi/skills_concurrency_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/pi/skills_concurrency_test.go rename to apps/daemon/internal/agent/pi/skills_concurrency_test.go diff --git a/apps/parsar-daemon/internal/agent/pi/skills_install.go b/apps/daemon/internal/agent/pi/skills_install.go similarity index 96% rename from apps/parsar-daemon/internal/agent/pi/skills_install.go rename to apps/daemon/internal/agent/pi/skills_install.go index 2a00e250b..d95c73fb6 100644 --- a/apps/parsar-daemon/internal/agent/pi/skills_install.go +++ b/apps/daemon/internal/agent/pi/skills_install.go @@ -10,8 +10,8 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/agent/pi/skills_test.go b/apps/daemon/internal/agent/pi/skills_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/pi/skills_test.go rename to apps/daemon/internal/agent/pi/skills_test.go diff --git a/apps/daemon/internal/agent/pi/version.go b/apps/daemon/internal/agent/pi/version.go new file mode 100644 index 000000000..b866cc054 --- /dev/null +++ b/apps/daemon/internal/agent/pi/version.go @@ -0,0 +1,36 @@ +package pi + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the pi documentation when the daemon +// can see the adapter but not the CLI binary. +const InstallURL = "https://github.com/earendil-works/pi" + +// defaultBinary is the executable to probe and spawn: binpath.Pi() +// honours the OAC_RUNTIME_PI_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.Pi() } + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot +// be located on PATH. Callers use errors.Is to distinguish an install +// problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("pi CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to defaultBinary(). +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "pi", + DefaultBinary: defaultBinary(), + MissingError: ErrCLINotFound, + TrimBinary: true, + StderrFallback: true, + }) +} diff --git a/apps/daemon/internal/agent/pi/version_test.go b/apps/daemon/internal/agent/pi/version_test.go new file mode 100644 index 000000000..778d108f7 --- /dev/null +++ b/apps/daemon/internal/agent/pi/version_test.go @@ -0,0 +1,30 @@ +package pi_test + +import ( + "context" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "pi", + DefaultBinary: "pi", + MissingError: pi.ErrCLINotFound, + Check: pi.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} + +func TestCheckCLIAvailableFallsBackToStderr(t *testing.T) { + stub := testutil.WriteStub(t, "stderr-pi", "#!/bin/sh\nprintf ' pi 0.74.2\\nextra line \\n' 1>&2\n") + version, err := pi.CheckCLIAvailable(context.Background(), stub) + if err != nil { + t.Fatalf("check CLI: %v", err) + } + if version != "pi 0.74.2" { + t.Fatalf("version = %q, want %q", version, "pi 0.74.2") + } +} diff --git a/apps/daemon/internal/agent/registry.go b/apps/daemon/internal/agent/registry.go new file mode 100644 index 000000000..4c6b5e815 --- /dev/null +++ b/apps/daemon/internal/agent/registry.go @@ -0,0 +1,121 @@ +package agent + +import ( + "errors" + "fmt" + "slices" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" +) + +// ErrUnknownPermission is returned by PermissionResponder.SubmitPermission when +// the permID doesn't match any outstanding request. The router uses +// this to distinguish a benign race from a real forwarding failure. +var ErrUnknownPermission = errors.New("agent: unknown permission id") + +// ErrUnknownAsk is returned by UserChoiceResponder.SubmitPromptForUserChoice when +// the askID doesn't match any outstanding ask. Same race semantics as +// ErrUnknownPermission. +var ErrUnknownAsk = errors.New("agent: unknown ask id") + +var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") + +// Registry maps agent_kind → Factory and keeps the daemon-advertised +// capability descriptor for each kind. Safe for concurrent use. +type Registry struct { + mu sync.RWMutex + factories map[string]Factory + preparers map[string]PreparationFactory + executors map[string]ExecutorFactory + kinds map[string]proto.SupportedAgentKind + configurations map[string]harnessconfig.Configuration +} + +func NewRegistry() *Registry { + return &Registry{ + factories: make(map[string]Factory), + preparers: make(map[string]PreparationFactory), + executors: make(map[string]ExecutorFactory), + kinds: make(map[string]proto.SupportedAgentKind), + configurations: make(map[string]harnessconfig.Configuration), + } +} + +// Resolve returns the factory for kind, or wraps ErrUnsupportedKind. +func (r *Registry) Resolve(kind string) (Factory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + f, ok := r.factories[kind] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnsupportedKind, kind) + } + return f, nil +} + +// Kinds returns the list of registered kinds sorted lexicographically. +func (r *Registry) Kinds() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.factories)) + for k := range r.factories { + out = append(out, k) + } + slices.Sort(out) + return out +} + +// SupportedAgentKinds returns the daemon-advertised capability +// descriptors sorted by kind so heartbeat payloads are stable. +func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]proto.SupportedAgentKind, 0, len(r.factories)) + for kind := range r.factories { + info := r.kinds[kind] + out = append(out, info) + } + slices.SortFunc(out, func(a, b proto.SupportedAgentKind) int { + if a.Kind < b.Kind { + return -1 + } + if a.Kind > b.Kind { + return 1 + } + return 0 + }) + return out +} + +func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + factory := r.executors[kind] + if factory == nil { + return nil, fmt.Errorf("agent: executor unavailable for %q", kind) + } + return factory, nil +} + +func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + f := r.preparers[kind] + if f == nil { + return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) + } + return f, nil +} + +// Configuration returns an owned declaration for registry wrappers. Wrappers +// transfer it with the factory; they must not infer configuration from kind names. +func (r *Registry) Configuration(kind string) (harnessconfig.Configuration, error) { + r.mu.RLock() + defer r.mu.RUnlock() + configuration, ok := r.configurations[kind] + if !ok { + return harnessconfig.Configuration{}, ErrUnsupportedKind + } + return configuration.Clone(), nil +} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go new file mode 100644 index 000000000..6bf4da382 --- /dev/null +++ b/apps/daemon/internal/agent/registry_test.go @@ -0,0 +1,191 @@ +package agent_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "reflect" + "slices" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +func stubFactory(marker string) agent.Factory { + return func(_ context.Context, _ proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + return stubSession{marker: marker}, nil + } +} + +type stubSession struct{ marker string } + +func (stubSession) Cancel(context.Context) error { return nil } +func (stubSession) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return nil +} +func (stubSession) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return nil +} + +func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + + f, err := reg.Resolve("claude_code") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "claude_code"}, nil) + if err != nil { + t.Fatalf("factory: %v", err) + } + stub, ok := sess.(stubSession) + if !ok || stub.marker != "cc" { + t.Errorf("resolved factory returned %#v, want stubSession{marker:\"cc\"}", sess) + } +} + +func TestRegistryResolveUnknownKindReturnsTypedError(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + + _, err := reg.Resolve("opencode") + if !errors.Is(err, agent.ErrUnsupportedKind) { + t.Errorf("Resolve unknown = %v, want ErrUnsupportedKind chain", err) + } +} + +func TestRegistryRegisterOverwrites(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v1")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v2")) + + f, err := reg.Resolve("k") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + sess, _ := f(context.Background(), proto.PromptRequestPayload{}, nil) + if got := sess.(stubSession).marker; got != "v2" { + t.Errorf("overwrite: marker = %q, want v2", got) + } +} + +func TestRegistryKindsReportsRegistered(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "opencode", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("oc")) + + got := reg.Kinds() + slices.Sort(got) + want := []string{"claude_code", "opencode"} + if !slices.Equal(got, want) { + t.Errorf("Kinds = %v, want %v", got, want) + } +} + +func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("Register(\"\", ...) did not panic") + } + }() + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("x")) +} + +func TestRegistryRegisterPanicsOnNilFactory(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("Register(kind, nil) did not panic") + } + }() + agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, nil) +} + +func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{ + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ + Streaming: proto.CapabilitySupported, + }), + }, harnessconfig.Configuration{}, stubFactory("oc")) + reg.RegisterKind(proto.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Version: "1.2.3", + Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ + Streaming: proto.CapabilitySupported, + Permissions: proto.CapabilitySupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, + }), + }, harnessconfig.Configuration{}, stubFactory("cc")) + + got := reg.SupportedAgentKinds() + if len(got) != 2 { + t.Fatalf("SupportedAgentKinds len = %d, want 2: %#v", len(got), got) + } + if got[0].Kind != "claude_code" || got[1].Kind != "opencode" { + t.Fatalf("SupportedAgentKinds sort = %#v, want claude_code then opencode", got) + } + if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Permissions.IsSupported() || !got[0].Capabilities.Resume.IsSupported() { + t.Fatalf("claude_code descriptor not preserved: %#v", got[0]) + } + if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.Streaming.IsSupported() { + t.Fatalf("opencode descriptor not preserved: %#v", got[1]) + } +} + +func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("native")) + if _, err := registry.ResolveExecutor("native"); err == nil { + t.Fatal("legacy factory implied reusable execution") + } + expected := errors.New("executor factory") + registry.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, expected }) + factory, err := registry.ResolveExecutor("native") + if err != nil { + t.Fatal(err) + } + if _, err := factory(t.Context(), proto.PromptRequestPayload{}); !errors.Is(err, expected) { + t.Fatal(err) + } + registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("replacement")) + if _, err := registry.ResolveExecutor("native"); err == nil { + t.Fatal("replacing a kind retained its old executor capability") + } +} + +func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { + valid := prototest.Capabilities(proto.AgentKindCapabilities{}) + for i := 0; i < reflect.TypeOf(valid).NumField(); i++ { + t.Run(reflect.TypeOf(valid).Field(i).Name, func(t *testing.T) { + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: valid}, harnessconfig.Configuration{}, stubFactory("original")) + missing := valid + reflect.ValueOf(&missing).Elem().Field(i).Set(reflect.ValueOf(proto.CapabilityUnspecified)) + func() { + defer func() { + if recover() == nil { + t.Error("incomplete declaration registered") + } + }() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Configuration{}, stubFactory("replacement")) + }() + factory, err := registry.Resolve("fixture") + if err != nil { + t.Fatal(err) + } + session, err := factory(t.Context(), proto.PromptRequestPayload{}, nil) + if err != nil || session.(stubSession).marker != "original" { + t.Fatal("failed declaration changed registry") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/runtime_paths.go b/apps/daemon/internal/agent/runtime_paths.go similarity index 96% rename from apps/parsar-daemon/internal/agent/runtime_paths.go rename to apps/daemon/internal/agent/runtime_paths.go index eddf6634d..5f78a1405 100644 --- a/apps/parsar-daemon/internal/agent/runtime_paths.go +++ b/apps/daemon/internal/agent/runtime_paths.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // ManagedSkillsRoot returns an adapter-owned skill directory scoped to one diff --git a/apps/parsar-daemon/internal/agent/runtime_paths_test.go b/apps/daemon/internal/agent/runtime_paths_test.go similarity index 100% rename from apps/parsar-daemon/internal/agent/runtime_paths_test.go rename to apps/daemon/internal/agent/runtime_paths_test.go diff --git a/apps/parsar-daemon/internal/agent/steering.go b/apps/daemon/internal/agent/steering.go similarity index 100% rename from apps/parsar-daemon/internal/agent/steering.go rename to apps/daemon/internal/agent/steering.go diff --git a/apps/parsar-daemon/internal/agent/unsupported.go b/apps/daemon/internal/agent/unsupported.go similarity index 100% rename from apps/parsar-daemon/internal/agent/unsupported.go rename to apps/daemon/internal/agent/unsupported.go diff --git a/apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go b/apps/daemon/internal/agent/versionprobe/testutil/testutil.go similarity index 100% rename from apps/parsar-daemon/internal/agent/versionprobe/testutil/testutil.go rename to apps/daemon/internal/agent/versionprobe/testutil/testutil.go diff --git a/apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go b/apps/daemon/internal/agent/versionprobe/versionprobe.go similarity index 100% rename from apps/parsar-daemon/internal/agent/versionprobe/versionprobe.go rename to apps/daemon/internal/agent/versionprobe/versionprobe.go diff --git a/apps/parsar-daemon/internal/agent/workspace_directory.go b/apps/daemon/internal/agent/workspace_directory.go similarity index 100% rename from apps/parsar-daemon/internal/agent/workspace_directory.go rename to apps/daemon/internal/agent/workspace_directory.go diff --git a/apps/parsar-daemon/internal/agent/workspace_read.go b/apps/daemon/internal/agent/workspace_read.go similarity index 100% rename from apps/parsar-daemon/internal/agent/workspace_read.go rename to apps/daemon/internal/agent/workspace_read.go diff --git a/apps/parsar-daemon/internal/agent/workspace_write.go b/apps/daemon/internal/agent/workspace_write.go similarity index 100% rename from apps/parsar-daemon/internal/agent/workspace_write.go rename to apps/daemon/internal/agent/workspace_write.go diff --git a/apps/parsar-daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go similarity index 96% rename from apps/parsar-daemon/internal/auth/store.go rename to apps/daemon/internal/auth/store.go index 1dea39040..3a69123e8 100644 --- a/apps/parsar-daemon/internal/auth/store.go +++ b/apps/daemon/internal/auth/store.go @@ -13,8 +13,8 @@ import ( "os" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // Profile is the on-disk representation of one paired credential. diff --git a/apps/parsar-daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go similarity index 97% rename from apps/parsar-daemon/internal/auth/store_test.go rename to apps/daemon/internal/auth/store_test.go index f9d28e404..1d76ad1b7 100644 --- a/apps/parsar-daemon/internal/auth/store_test.go +++ b/apps/daemon/internal/auth/store_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) func withTempHome(t *testing.T) string { diff --git a/apps/parsar-daemon/internal/authoring/bridge.go b/apps/daemon/internal/authoring/bridge.go similarity index 98% rename from apps/parsar-daemon/internal/authoring/bridge.go rename to apps/daemon/internal/authoring/bridge.go index 4d523650b..519165fda 100644 --- a/apps/parsar-daemon/internal/authoring/bridge.go +++ b/apps/daemon/internal/authoring/bridge.go @@ -14,7 +14,7 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type Sender interface { diff --git a/apps/parsar-daemon/internal/authoring/bridge_test.go b/apps/daemon/internal/authoring/bridge_test.go similarity index 97% rename from apps/parsar-daemon/internal/authoring/bridge_test.go rename to apps/daemon/internal/authoring/bridge_test.go index f4bf9cd00..17056693d 100644 --- a/apps/parsar-daemon/internal/authoring/bridge_test.go +++ b/apps/daemon/internal/authoring/bridge_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type testSender struct{ frames chan proto.Envelope } diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go similarity index 96% rename from apps/parsar-daemon/internal/cli/agent_discovery.go rename to apps/daemon/internal/cli/agent_discovery.go index 9e647a7c0..2f46dc32a 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/daemon/internal/cli/agent_discovery.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go similarity index 77% rename from apps/parsar-daemon/internal/cli/agent_registration.go rename to apps/daemon/internal/cli/agent_registration.go index 1197e2cb1..f80d1c1b1 100644 --- a/apps/parsar-daemon/internal/cli/agent_registration.go +++ b/apps/daemon/internal/cli/agent_registration.go @@ -1,18 +1,18 @@ package cli -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" import ( "context" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, serverURL string) { diff --git a/apps/parsar-daemon/internal/cli/authoring.go b/apps/daemon/internal/cli/authoring.go similarity index 90% rename from apps/parsar-daemon/internal/cli/authoring.go rename to apps/daemon/internal/cli/authoring.go index 01e336faa..6b3b26956 100644 --- a/apps/parsar-daemon/internal/cli/authoring.go +++ b/apps/daemon/internal/cli/authoring.go @@ -6,9 +6,9 @@ import ( "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func withAuthoringBridge(factory agent.Factory, bridge *authoring.Bridge) agent.Factory { diff --git a/apps/parsar-daemon/internal/cli/authoring_test.go b/apps/daemon/internal/cli/authoring_test.go similarity index 89% rename from apps/parsar-daemon/internal/cli/authoring_test.go rename to apps/daemon/internal/cli/authoring_test.go index 7cb9db5f5..709109256 100644 --- a/apps/parsar-daemon/internal/cli/authoring_test.go +++ b/apps/daemon/internal/cli/authoring_test.go @@ -1,6 +1,6 @@ package cli -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestAuthoringSocketEndsWithTurnWhileSessionIsRetained(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/capability_downloads.go b/apps/daemon/internal/cli/capability_downloads.go similarity index 94% rename from apps/parsar-daemon/internal/cli/capability_downloads.go rename to apps/daemon/internal/cli/capability_downloads.go index e92fdfc0c..c6def5f15 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads.go +++ b/apps/daemon/internal/cli/capability_downloads.go @@ -8,8 +8,8 @@ import ( "slices" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Use the paired server address for loopback PG downloads: inside Compose, diff --git a/apps/parsar-daemon/internal/cli/capability_downloads_test.go b/apps/daemon/internal/cli/capability_downloads_test.go similarity index 95% rename from apps/parsar-daemon/internal/cli/capability_downloads_test.go rename to apps/daemon/internal/cli/capability_downloads_test.go index fb63b1775..59269b64a 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads_test.go +++ b/apps/daemon/internal/cli/capability_downloads_test.go @@ -13,9 +13,9 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCapabilityDownloadUsesPairedServerOnlyForLoopbackPG(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/daemon/internal/cli/claude_sdk.go similarity index 94% rename from apps/parsar-daemon/internal/cli/claude_sdk.go rename to apps/daemon/internal/cli/claude_sdk.go index 6b9cc2c02..5a6567526 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/daemon/internal/cli/claude_sdk.go @@ -1,6 +1,6 @@ package cli -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" import ( "context" @@ -9,11 +9,11 @@ import ( "os/exec" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const claudeSDKEntrypointEnv = "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go similarity index 96% rename from apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go rename to apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 468fd2681..9b02e7152 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/daemon/internal/cli/claude_sdk_test.go similarity index 95% rename from apps/parsar-daemon/internal/cli/claude_sdk_test.go rename to apps/daemon/internal/cli/claude_sdk_test.go index 67812af4f..aaa930843 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/daemon/internal/cli/claude_sdk_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func unavailableCLIChecks() agentCLIChecks { diff --git a/apps/parsar-daemon/internal/cli/companion_path_test.go b/apps/daemon/internal/cli/companion_path_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/companion_path_test.go rename to apps/daemon/internal/cli/companion_path_test.go diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go similarity index 95% rename from apps/parsar-daemon/internal/cli/connect.go rename to apps/daemon/internal/cli/connect.go index 7ea1d4830..ebd412693 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -10,17 +10,17 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) const ( diff --git a/apps/parsar-daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go similarity index 76% rename from apps/parsar-daemon/internal/cli/connect_bootstrap.go rename to apps/daemon/internal/cli/connect_bootstrap.go index 00c715b55..4092479dc 100644 --- a/apps/parsar-daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -2,9 +2,9 @@ package cli import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "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 diff --git a/apps/parsar-daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go similarity index 92% rename from apps/parsar-daemon/internal/cli/connect_bootstrap_test.go rename to apps/daemon/internal/cli/connect_bootstrap_test.go index 2ee9a6950..c3fc68a4d 100644 --- a/apps/parsar-daemon/internal/cli/connect_bootstrap_test.go +++ b/apps/daemon/internal/cli/connect_bootstrap_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" ) func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup.go b/apps/daemon/internal/cli/connect_cleanup.go similarity index 91% rename from apps/parsar-daemon/internal/cli/connect_cleanup.go rename to apps/daemon/internal/cli/connect_cleanup.go index 7909d7347..a5a0b7351 100644 --- a/apps/parsar-daemon/internal/cli/connect_cleanup.go +++ b/apps/daemon/internal/cli/connect_cleanup.go @@ -4,7 +4,7 @@ import ( "context" "time" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // A wait deadline does not revoke native ownership. Keep retrying the same diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go similarity index 94% rename from apps/parsar-daemon/internal/cli/connect_cleanup_test.go rename to apps/daemon/internal/cli/connect_cleanup_test.go index f89a3aee0..e8ef2d21e 100644 --- a/apps/parsar-daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -1,6 +1,6 @@ package cli -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,10 +10,10 @@ import ( "os" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "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/gorilla/websocket" "sync/atomic" "testing" diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go similarity index 96% rename from apps/parsar-daemon/internal/cli/connect_environment.go rename to apps/daemon/internal/cli/connect_environment.go index 65440c9c1..de92018ee 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -13,10 +13,10 @@ import ( "os" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "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" ) diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go similarity index 96% rename from apps/parsar-daemon/internal/cli/connect_environment_binding.go rename to apps/daemon/internal/cli/connect_environment_binding.go index df771a66f..9fa651798 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -6,10 +6,10 @@ import ( "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "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/runtimefs" ) // This receipt contains identity only; executor credentials remain in their file. diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/daemon/internal/cli/connect_environment_binding_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/connect_environment_binding_test.go rename to apps/daemon/internal/cli/connect_environment_binding_test.go diff --git a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go b/apps/daemon/internal/cli/connect_environment_park_test.go similarity index 98% rename from apps/parsar-daemon/internal/cli/connect_environment_park_test.go rename to apps/daemon/internal/cli/connect_environment_park_test.go index 9d15f20ef..4dade9407 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go +++ b/apps/daemon/internal/cli/connect_environment_park_test.go @@ -17,7 +17,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go similarity index 99% rename from apps/parsar-daemon/internal/cli/connect_environment_test.go rename to apps/daemon/internal/cli/connect_environment_test.go index 2415ff942..9bea081d2 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go similarity index 95% rename from apps/parsar-daemon/internal/cli/connect_suspend.go rename to apps/daemon/internal/cli/connect_suspend.go index 149716ce1..5050d6e06 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend.go +++ b/apps/daemon/internal/cli/connect_suspend.go @@ -6,13 +6,13 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) const suspendReconnectTimeout = 30 * time.Second diff --git a/apps/parsar-daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go similarity index 98% rename from apps/parsar-daemon/internal/cli/connect_suspend_test.go rename to apps/daemon/internal/cli/connect_suspend_test.go index 80e1961c5..4c7177746 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -15,9 +15,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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/gorilla/websocket" ) diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/daemon/internal/cli/connect_test.go similarity index 94% rename from apps/parsar-daemon/internal/cli/connect_test.go rename to apps/daemon/internal/cli/connect_test.go index 99facf4b6..51ec733c3 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/daemon/internal/cli/connect_test.go @@ -7,14 +7,14 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/logout.go b/apps/daemon/internal/cli/logout.go similarity index 90% rename from apps/parsar-daemon/internal/cli/logout.go rename to apps/daemon/internal/cli/logout.go index e73c8f270..553c6fa57 100644 --- a/apps/parsar-daemon/internal/cli/logout.go +++ b/apps/daemon/internal/cli/logout.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "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 diff --git a/apps/parsar-daemon/internal/cli/logs.go b/apps/daemon/internal/cli/logs.go similarity index 92% rename from apps/parsar-daemon/internal/cli/logs.go rename to apps/daemon/internal/cli/logs.go index 9e76ec7be..cc8132037 100644 --- a/apps/parsar-daemon/internal/cli/logs.go +++ b/apps/daemon/internal/cli/logs.go @@ -8,8 +8,8 @@ import ( "os/signal" "syscall" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // runLogs tails ~/.oac/daemon//connect.log. -f streams diff --git a/apps/daemon/internal/cli/mcode.go b/apps/daemon/internal/cli/mcode.go new file mode 100644 index 000000000..38774e77d --- /dev/null +++ b/apps/daemon/internal/cli/mcode.go @@ -0,0 +1,71 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func discoverMCode(parent context.Context, rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { + if check == nil { + check = mcode.CheckCLIAvailable + } + result := proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ + SubagentObservations: proto.CapabilityUnsupported, + Streaming: proto.CapabilitySupported, + Permissions: proto.CapabilitySupported, + Usage: proto.CapabilityUnsupported, + Resume: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + WorkspaceAuthoring: proto.CapabilityUnsupported, + Steering: proto.CapabilityUnsupported, + MessageItems: proto.CapabilityUnsupported, + ToolObservations: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilityUnsupported, + LocalEnvironment: proto.CapabilityUnsupported, + Preparation: proto.CapabilityUnsupported, + WorkspaceReadPreparation: proto.CapabilityUnsupported, + WorkspaceOutputExport: proto.CapabilityUnsupported, + ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, + WebSearchControl: proto.CapabilityUnsupported, + ExecutionControls: proto.CapabilityUnsupported, + TextVerbosity: proto.CapabilityUnsupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + SubagentControl: proto.CapabilityUnsupported, + DurableInputReceipts: proto.CapabilityUnsupported, + DurableTurns: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + MCPHTTPTools: proto.CapabilityUnsupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilityUnsupported, + }} + ctx, cancel := context.WithTimeout(parent, cliVersionTimeout) + defer cancel() + version, err := check(ctx, "") + if err != nil { + fmt.Fprintf(rc.stderr, "oac-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) + return result + } + result.Available, result.Version = true, version + if mcode.SupportsExecution(version) { + result.Capabilities.Steering = proto.CapabilitySupported + result.Capabilities.DurableTurns = proto.CapabilitySupported + result.Capabilities.DurableInputReceipts = proto.CapabilitySupported + result.Capabilities.ExecutionControls = proto.CapabilitySupported + result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported + result.Capabilities.ToolObservations = proto.CapabilitySupported + result.Capabilities.SubagentControl = proto.CapabilitySupported + // Native preparation verifies the applied admission/tool profile before input. + result.Capabilities.SubagentObservations = proto.CapabilitySupported + result.Capabilities.EnvironmentNone = proto.CapabilitySupported + result.Capabilities.MCPHTTPTools = proto.CapabilitySupported + result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported + } + fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) + return result +} diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/daemon/internal/cli/mcode_execution_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/mcode_execution_test.go rename to apps/daemon/internal/cli/mcode_execution_test.go diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/daemon/internal/cli/mcode_workspace.go similarity index 81% rename from apps/parsar-daemon/internal/cli/mcode_workspace.go rename to apps/daemon/internal/cli/mcode_workspace.go index e759a77d5..ee634d6af 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/daemon/internal/cli/mcode_workspace.go @@ -7,11 +7,11 @@ import ( "os/exec" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func discoverMCodeWorkspace(parent context.Context, rc *runContext, discovery *agentCLIDiscovery) { diff --git a/apps/daemon/internal/cli/mcp_test.go b/apps/daemon/internal/cli/mcp_test.go new file mode 100644 index 000000000..e66412799 --- /dev/null +++ b/apps/daemon/internal/cli/mcp_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "") + checks := unavailableCLIChecks() + checks.Codex = func(context.Context, string) (string, error) { return "codex 0.153.4", nil } + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + if err != nil { + t.Fatal(err) + } + registry := agent.NewRegistry() + registerAgentKinds(registry, discovery, "https://service.example") + for _, kind := range registry.SupportedAgentKinds() { + if kind.Capabilities.MCPHTTPBearerAuth.IsSupported() != (kind.Kind == "codex") { + t.Fatal("bearer capability missing or advertised for another adapter") + } + if kind.Kind == "codex" && (!kind.Available || !kind.Capabilities.MCPHTTPTools.IsSupported() || !kind.Capabilities.EnvironmentNone.IsSupported()) { + t.Fatal("bearer capability lacks prerequisite profile") + } + } +} + +func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { + for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { + checks := unavailableCLIChecks() + checks.Codex = func(context.Context, string) (string, error) { return version, nil } + got, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + if err != nil { + t.Fatal(err) + } + if got.Codex.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native combination advertised") + } + if got.Codex.Capabilities.NativeSessionRecovery.IsSupported() != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native recovery advertised") + } + if got.ClaudeCode.Capabilities.MCPHTTPRequired.IsSupported() || got.OpenCode.Capabilities.MCPHTTPRequired.IsSupported() || got.Pi.Capabilities.MCPHTTPRequired.IsSupported() { + t.Fatal("other engine advertised combination") + } + } +} diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/daemon/internal/cli/native_bundle.go similarity index 100% rename from apps/parsar-daemon/internal/cli/native_bundle.go rename to apps/daemon/internal/cli/native_bundle.go diff --git a/apps/parsar-daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go similarity index 92% rename from apps/parsar-daemon/internal/cli/native_discovery_test.go rename to apps/daemon/internal/cli/native_discovery_test.go index 67a74543d..cedf1c5a2 100644 --- a/apps/parsar-daemon/internal/cli/native_discovery_test.go +++ b/apps/daemon/internal/cli/native_discovery_test.go @@ -8,8 +8,8 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" ) func TestNativeInstallationDiscoversAndRegistersOnlySelectedHarness(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/daemon/internal/cli/native_harness.go similarity index 88% rename from apps/parsar-daemon/internal/cli/native_harness.go rename to apps/daemon/internal/cli/native_harness.go index 41d6cb766..69aa94b91 100644 --- a/apps/parsar-daemon/internal/cli/native_harness.go +++ b/apps/daemon/internal/cli/native_harness.go @@ -10,11 +10,11 @@ import ( "runtime" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" ) func nativeExe(name string) string { diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go similarity index 98% rename from apps/parsar-daemon/internal/cli/native_install.go rename to apps/daemon/internal/cli/native_install.go index 9cc860051..60d120344 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -14,9 +14,9 @@ import ( "path/filepath" "slices" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "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" ) // Installation is local state, not an options-file input or an OS service. diff --git a/apps/parsar-daemon/internal/cli/native_install_input.go b/apps/daemon/internal/cli/native_install_input.go similarity index 98% rename from apps/parsar-daemon/internal/cli/native_install_input.go rename to apps/daemon/internal/cli/native_install_input.go index bf95895bd..f8a18c8fb 100644 --- a/apps/parsar-daemon/internal/cli/native_install_input.go +++ b/apps/daemon/internal/cli/native_install_input.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) type nativeInstallOptions struct { diff --git a/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go rename to apps/daemon/internal/cli/native_install_interrupt_unix_test.go diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/native_install_test.go rename to apps/daemon/internal/cli/native_install_test.go diff --git a/apps/parsar-daemon/internal/cli/native_onboarding.go b/apps/daemon/internal/cli/native_onboarding.go similarity index 96% rename from apps/parsar-daemon/internal/cli/native_onboarding.go rename to apps/daemon/internal/cli/native_onboarding.go index b76f6a204..cfecf5cb7 100644 --- a/apps/parsar-daemon/internal/cli/native_onboarding.go +++ b/apps/daemon/internal/cli/native_onboarding.go @@ -16,11 +16,11 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "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" ) func installationRequest(ctx context.Context, endpoint, authorization string, input any, output any) error { diff --git a/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go b/apps/daemon/internal/cli/native_start_interrupt_unix_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go rename to apps/daemon/internal/cli/native_start_interrupt_unix_test.go diff --git a/apps/parsar-daemon/internal/cli/pair.go b/apps/daemon/internal/cli/pair.go similarity index 97% rename from apps/parsar-daemon/internal/cli/pair.go rename to apps/daemon/internal/cli/pair.go index ba4c3159f..c5b65cee4 100644 --- a/apps/parsar-daemon/internal/cli/pair.go +++ b/apps/daemon/internal/cli/pair.go @@ -12,8 +12,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" ) func pairProfile(ctx context.Context, serverURL, token, deviceName string) (auth.Profile, *pairResponse, error) { diff --git a/apps/parsar-daemon/internal/cli/pair_test.go b/apps/daemon/internal/cli/pair_test.go similarity index 98% rename from apps/parsar-daemon/internal/cli/pair_test.go rename to apps/daemon/internal/cli/pair_test.go index 7331d36d9..620b5ed67 100644 --- a/apps/parsar-daemon/internal/cli/pair_test.go +++ b/apps/daemon/internal/cli/pair_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" ) // Regression for the "runner_public_key required" pair failure: the diff --git a/apps/parsar-daemon/internal/cli/placement.go b/apps/daemon/internal/cli/placement.go similarity index 97% rename from apps/parsar-daemon/internal/cli/placement.go rename to apps/daemon/internal/cli/placement.go index e0cc8a36d..f0016eed2 100644 --- a/apps/parsar-daemon/internal/cli/placement.go +++ b/apps/daemon/internal/cli/placement.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/placement" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/placement" ) func runPlacement(ctx *runContext, args []string) error { diff --git a/apps/daemon/internal/cli/preparation_test.go b/apps/daemon/internal/cli/preparation_test.go new file mode 100644 index 000000000..6d26eff60 --- /dev/null +++ b/apps/daemon/internal/cli/preparation_test.go @@ -0,0 +1,54 @@ +package cli + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { + for _, supported := range []bool{false, true} { + reg := agent.NewRegistry() + registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, OpenCode: proto.SupportedAgentKind{Kind: "opencode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, Pi: proto.SupportedAgentKind{Kind: "pi", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, MCode: proto.SupportedAgentKind{Kind: "mcode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}}, "http://unreachable.invalid") + _, err := reg.ResolvePreparation("codex") + if (err == nil) != supported { + t.Fatal("unverified native version advertised preparation") + } + if !supported { + continue + } + stop := errors.New("controlled preparation stop") + reg.RegisterPreparation("codex", true, func(_ context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if req.RunID != "" || req.WorkspaceAuthoring || len(req.AgentOptions) != 0 { + t.Error("product wrapper injected preparation context") + } + return nil, stop + }) + wrapped := authoringRegistry(reg, authoring.New(nil)) + prepare, err := wrapped.ResolvePreparation("codex") + if err != nil { + t.Fatal(err) + } + if _, err := prepare(t.Context(), proto.PromptRequestPayload{AgentKind: "codex"}); !errors.Is(err, stop) { + t.Fatal("raw preparation was lost or wrapped", err) + } + for _, info := range wrapped.SupportedAgentKinds() { + if info.Kind == "codex" && (!info.Capabilities.Preparation.IsSupported() || !info.Capabilities.WorkspaceReadPreparation.IsSupported()) { + t.Fatal("real heartbeat registry lost preparation") + } + } + wrapped.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, stop + }) + if _, err := wrapped.ResolvePreparation("codex"); err == nil { + t.Fatal("factory replacement retained stale preparation") + } + } +} diff --git a/apps/parsar-daemon/internal/cli/retired_configuration.go b/apps/daemon/internal/cli/retired_configuration.go similarity index 100% rename from apps/parsar-daemon/internal/cli/retired_configuration.go rename to apps/daemon/internal/cli/retired_configuration.go diff --git a/apps/parsar-daemon/internal/cli/retired_configuration_test.go b/apps/daemon/internal/cli/retired_configuration_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/retired_configuration_test.go rename to apps/daemon/internal/cli/retired_configuration_test.go diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go similarity index 100% rename from apps/parsar-daemon/internal/cli/root.go rename to apps/daemon/internal/cli/root.go diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/daemon/internal/cli/root_test.go similarity index 100% rename from apps/parsar-daemon/internal/cli/root_test.go rename to apps/daemon/internal/cli/root_test.go diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/daemon/internal/cli/runtime_mcp.go similarity index 95% rename from apps/parsar-daemon/internal/cli/runtime_mcp.go rename to apps/daemon/internal/cli/runtime_mcp.go index bca7cab0d..e3b62a039 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/daemon/internal/cli/runtime_mcp.go @@ -8,8 +8,8 @@ import ( "sort" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" ) var errRuntimeMCP = errors.New("environment MCP unavailable") diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/daemon/internal/cli/runtime_mcp_test.go similarity index 95% rename from apps/parsar-daemon/internal/cli/runtime_mcp_test.go rename to apps/daemon/internal/cli/runtime_mcp_test.go index ec8366c79..9e592d142 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go +++ b/apps/daemon/internal/cli/runtime_mcp_test.go @@ -4,8 +4,8 @@ import ( "slices" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go b/apps/daemon/internal/cli/runtime_mcp_unix.go similarity index 100% rename from apps/parsar-daemon/internal/cli/runtime_mcp_unix.go rename to apps/daemon/internal/cli/runtime_mcp_unix.go diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/daemon/internal/cli/runtime_mcp_windows.go similarity index 88% rename from apps/parsar-daemon/internal/cli/runtime_mcp_windows.go rename to apps/daemon/internal/cli/runtime_mcp_windows.go index 35606bfd7..d8e17fa7f 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go +++ b/apps/daemon/internal/cli/runtime_mcp_windows.go @@ -6,7 +6,7 @@ import ( "os" "os/exec" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" ) func execRuntimeMCP(invocation mcpInvocation) error { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go b/apps/daemon/internal/cli/runtime_mcp_windows_test.go similarity index 98% rename from apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go rename to apps/daemon/internal/cli/runtime_mcp_windows_test.go index 0d1179240..8b17a1a66 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go +++ b/apps/daemon/internal/cli/runtime_mcp_windows_test.go @@ -4,8 +4,8 @@ package cli import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "os" "os/exec" "path/filepath" diff --git a/apps/parsar-daemon/internal/cli/skill_upload.go b/apps/daemon/internal/cli/skill_upload.go similarity index 89% rename from apps/parsar-daemon/internal/cli/skill_upload.go rename to apps/daemon/internal/cli/skill_upload.go index c77b0fefe..cdb7ef279 100644 --- a/apps/parsar-daemon/internal/cli/skill_upload.go +++ b/apps/daemon/internal/cli/skill_upload.go @@ -6,8 +6,8 @@ import ( "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { diff --git a/apps/parsar-daemon/internal/cli/skill_upload_test.go b/apps/daemon/internal/cli/skill_upload_test.go similarity index 91% rename from apps/parsar-daemon/internal/cli/skill_upload_test.go rename to apps/daemon/internal/cli/skill_upload_test.go index 28550acb8..ef8fc3921 100644 --- a/apps/parsar-daemon/internal/cli/skill_upload_test.go +++ b/apps/daemon/internal/cli/skill_upload_test.go @@ -5,8 +5,8 @@ import ( "os" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestSkillUploadUsesPairedAddressPerRequest(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go similarity index 94% rename from apps/parsar-daemon/internal/cli/status.go rename to apps/daemon/internal/cli/status.go index dc1e8d4e0..d02019d45 100644 --- a/apps/parsar-daemon/internal/cli/status.go +++ b/apps/daemon/internal/cli/status.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // runStatus prints a one-screen profile summary. Deliberately omits diff --git a/apps/parsar-daemon/internal/cli/stop.go b/apps/daemon/internal/cli/stop.go similarity index 90% rename from apps/parsar-daemon/internal/cli/stop.go rename to apps/daemon/internal/cli/stop.go index eb20c5d99..2ababd8ab 100644 --- a/apps/parsar-daemon/internal/cli/stop.go +++ b/apps/daemon/internal/cli/stop.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // runStop requests cleanup from the recorded daemon identity. A timeout keeps diff --git a/apps/parsar-daemon/internal/cli/suspend_control.go b/apps/daemon/internal/cli/suspend_control.go similarity index 98% rename from apps/parsar-daemon/internal/cli/suspend_control.go rename to apps/daemon/internal/cli/suspend_control.go index eedb7a545..9da262eeb 100644 --- a/apps/parsar-daemon/internal/cli/suspend_control.go +++ b/apps/daemon/internal/cli/suspend_control.go @@ -12,7 +12,7 @@ import ( "path/filepath" "syscall" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" diff --git a/apps/parsar-daemon/internal/cli/suspend_control_linux_test.go b/apps/daemon/internal/cli/suspend_control_linux_test.go similarity index 96% rename from apps/parsar-daemon/internal/cli/suspend_control_linux_test.go rename to apps/daemon/internal/cli/suspend_control_linux_test.go index 556da95c3..83eaa1601 100644 --- a/apps/parsar-daemon/internal/cli/suspend_control_linux_test.go +++ b/apps/daemon/internal/cli/suspend_control_linux_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T) { diff --git a/apps/parsar-daemon/internal/cli/suspend_process_linux.go b/apps/daemon/internal/cli/suspend_process_linux.go similarity index 100% rename from apps/parsar-daemon/internal/cli/suspend_process_linux.go rename to apps/daemon/internal/cli/suspend_process_linux.go diff --git a/apps/parsar-daemon/internal/cli/suspend_process_other.go b/apps/daemon/internal/cli/suspend_process_other.go similarity index 93% rename from apps/parsar-daemon/internal/cli/suspend_process_other.go rename to apps/daemon/internal/cli/suspend_process_other.go index 42073245d..5d57afa2d 100644 --- a/apps/parsar-daemon/internal/cli/suspend_process_other.go +++ b/apps/daemon/internal/cli/suspend_process_other.go @@ -5,7 +5,7 @@ package cli import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "os" ) diff --git a/apps/parsar-daemon/internal/contracttest/wire_test.go b/apps/daemon/internal/contracttest/wire_test.go similarity index 95% rename from apps/parsar-daemon/internal/contracttest/wire_test.go rename to apps/daemon/internal/contracttest/wire_test.go index 8214eaf48..9e4586c3a 100644 --- a/apps/parsar-daemon/internal/contracttest/wire_test.go +++ b/apps/daemon/internal/contracttest/wire_test.go @@ -2,7 +2,7 @@ // using the production gateway, transport and dispatcher with a controlled adapter. package contracttest -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -15,13 +15,13 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "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/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) const credential = "synthetic-contract-credential" diff --git a/apps/parsar-daemon/internal/daemonize/fork.go b/apps/daemon/internal/daemonize/fork.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/fork.go rename to apps/daemon/internal/daemonize/fork.go diff --git a/apps/parsar-daemon/internal/daemonize/fork_test.go b/apps/daemon/internal/daemonize/fork_test.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/fork_test.go rename to apps/daemon/internal/daemonize/fork_test.go diff --git a/apps/parsar-daemon/internal/daemonize/fork_unix.go b/apps/daemon/internal/daemonize/fork_unix.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/fork_unix.go rename to apps/daemon/internal/daemonize/fork_unix.go diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/daemon/internal/daemonize/fork_windows.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/fork_windows.go rename to apps/daemon/internal/daemonize/fork_windows.go diff --git a/apps/parsar-daemon/internal/daemonize/helpers_test.go b/apps/daemon/internal/daemonize/helpers_test.go similarity index 94% rename from apps/parsar-daemon/internal/daemonize/helpers_test.go rename to apps/daemon/internal/daemonize/helpers_test.go index 6f32354cc..cc496725c 100644 --- a/apps/parsar-daemon/internal/daemonize/helpers_test.go +++ b/apps/daemon/internal/daemonize/helpers_test.go @@ -3,7 +3,7 @@ package daemonize import ( "context" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "os" "path/filepath" "testing" diff --git a/apps/parsar-daemon/internal/daemonize/logfile.go b/apps/daemon/internal/daemonize/logfile.go similarity index 98% rename from apps/parsar-daemon/internal/daemonize/logfile.go rename to apps/daemon/internal/daemonize/logfile.go index c83de6f6a..80124e1fc 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile.go +++ b/apps/daemon/internal/daemonize/logfile.go @@ -4,7 +4,7 @@ import ( "bufio" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "io" "os" "path/filepath" diff --git a/apps/parsar-daemon/internal/daemonize/logfile_test.go b/apps/daemon/internal/daemonize/logfile_test.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/logfile_test.go rename to apps/daemon/internal/daemonize/logfile_test.go diff --git a/apps/parsar-daemon/internal/daemonize/notify_unix.go b/apps/daemon/internal/daemonize/notify_unix.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/notify_unix.go rename to apps/daemon/internal/daemonize/notify_unix.go diff --git a/apps/parsar-daemon/internal/daemonize/notify_windows.go b/apps/daemon/internal/daemonize/notify_windows.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/notify_windows.go rename to apps/daemon/internal/daemonize/notify_windows.go diff --git a/apps/parsar-daemon/internal/daemonize/pidfile.go b/apps/daemon/internal/daemonize/pidfile.go similarity index 97% rename from apps/parsar-daemon/internal/daemonize/pidfile.go rename to apps/daemon/internal/daemonize/pidfile.go index 0a5f548e3..2224c8542 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile.go +++ b/apps/daemon/internal/daemonize/pidfile.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "os" "path/filepath" "time" diff --git a/apps/parsar-daemon/internal/daemonize/pidfile_test.go b/apps/daemon/internal/daemonize/pidfile_test.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/pidfile_test.go rename to apps/daemon/internal/daemonize/pidfile_test.go diff --git a/apps/parsar-daemon/internal/daemonize/process_darwin.go b/apps/daemon/internal/daemonize/process_darwin.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/process_darwin.go rename to apps/daemon/internal/daemonize/process_darwin.go diff --git a/apps/parsar-daemon/internal/daemonize/process_linux.go b/apps/daemon/internal/daemonize/process_linux.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/process_linux.go rename to apps/daemon/internal/daemonize/process_linux.go diff --git a/apps/parsar-daemon/internal/daemonize/process_windows.go b/apps/daemon/internal/daemonize/process_windows.go similarity index 100% rename from apps/parsar-daemon/internal/daemonize/process_windows.go rename to apps/daemon/internal/daemonize/process_windows.go diff --git a/apps/daemon/internal/dispatch/cancellation.go b/apps/daemon/internal/dispatch/cancellation.go new file mode 100644 index 000000000..41b850d5f --- /dev/null +++ b/apps/daemon/internal/dispatch/cancellation.go @@ -0,0 +1,81 @@ +package dispatch + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type cancellationOutcomeProvider interface { + CancellationOutcome() proto.DonePayload +} + +func (r *Router) releaseCompletedSession(state *sessionState) error { + r.mu.Lock() + state.retain = false + r.mu.Unlock() + receiptErr := r.finishSteering(state) + err := state.session.Cancel(context.Background()) + state.ctxCancel() + return errors.Join(receiptErr, err) +} + +func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { + var request proto.PromptCancelPayload + if err := env.DecodePayload(&request); err != nil { + return err + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + state := r.sessions[env.ID] + if state != nil { + state.retain = false + } + var cancelSession func(context.Context) error + if state != nil && state.preparedHandoff != nil { + handoff := state.preparedHandoff + release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) + if request.DeliveryID != "" { + r.shutdownWG.Add(1) + go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) + } + r.mu.Unlock() + return nil + } + if state != nil && state.session != nil { + cancelSession = state.session.Cancel + } + r.mu.Unlock() + ack := proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"} + if state != nil && cancelSession == nil { + ack.ErrorCode = "not_ready" + } else if cancelSession != nil { + if err := cancelSession(ctx); err != nil { + r.log.WarnContext(ctx, "session.Cancel failed", "run_id", env.ID, "err", err) + ack.ErrorCode = "cancel_failed" + } else { + ack.Applied, ack.ErrorCode = true, "" + if provider, ok := state.session.(cancellationOutcomeProvider); ok { + outcome := provider.CancellationOutcome() + ack.Outcome = &outcome + } + } + state.ctxCancel() + } + return r.sendCancellationAck(ctx, env, ack) +} + +func (r *Router) sendCancellationAck(ctx context.Context, env proto.Envelope, ack proto.InteractionDecisionAckPayload) error { + if ack.DeliveryID == "" { + return nil + } + reply, err := proto.NewEnvelopeWithTrace(proto.TypeInteractionDecisionAck, env.ID, ack, env.Trace) + if err != nil { + return err + } + return r.sender.Send(ctx, reply) +} diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go new file mode 100644 index 000000000..d058d8bfb --- /dev/null +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -0,0 +1,127 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type cancelReceiptSession struct { + *fakeSession + entered chan struct{} + release chan struct{} + err error +} + +func (s *cancelReceiptSession) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} +} + +func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + }) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "release", proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true})); err != nil { + t.Fatal(err) + } + sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) + <-sess.entered + if len(h.sender.snapshot()) != 0 { + t.Fatal("completion acknowledged before native writer was released") + } + close(sess.release) + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "release completion") + frames := h.sender.snapshot() + if len(frames) != 1 || frames[0].Type != proto.TypeDone || sess.cancels() != 1 { + t.Fatal("completion or native release missing") + } +} + +func (s *cancelReceiptSession) Cancel(ctx context.Context) error { + if s.entered != nil { + close(s.entered) + <-s.release + s.entered = nil + } + _ = s.fakeSession.Cancel(ctx) + return s.err +} + +func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { + for _, fails := range []bool{false, true} { + t.Run(map[bool]string{false: "applied", true: "rejected"}[fails], func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} + if fails { + sess.err = errors.New("adapter could not cancel") + } + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} + return sess, nil + }) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})) + }() + <-sess.entered + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + t.Fatal("cancellation acknowledged before adapter returned") + } + } + close(sess.release) + if err := <-done; err != nil { + t.Fatal(err) + } + found := false + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + found = true + var ack proto.InteractionDecisionAckPayload + _ = env.DecodePayload(&ack) + if ack.Applied == fails || ack.DeliveryID != "cancel-1" { + t.Fatalf("wrong receipt: %+v", ack) + } + if !fails && (ack.Outcome == nil || ack.Outcome.Metadata[proto.DoneMetaAgentSessionID] != "native-cancelled") { + t.Fatal("cancellation receipt lost native identity") + } + } + } + if !found { + t.Fatal("missing cancellation receipt") + } + }) + } +} + +func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatal(err) + } + sess := <-h.gotSess + sess.closeOutOnCancel = true + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "legacy", proto.PromptCancelPayload{})); err != nil { + t.Fatal(err) + } + for _, env := range h.sender.snapshot() { + if env.Type == proto.TypeInteractionDecisionAck { + t.Fatal("legacy cancellation emitted new receipt") + } + } +} diff --git a/apps/daemon/internal/dispatch/capabilities.go b/apps/daemon/internal/dispatch/capabilities.go new file mode 100644 index 000000000..66910b645 --- /dev/null +++ b/apps/daemon/internal/dispatch/capabilities.go @@ -0,0 +1,12 @@ +package dispatch + +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + +func (r *Router) availableCapabilities(kind string) (proto.AgentKindCapabilities, bool) { + for _, info := range r.registry.SupportedAgentKinds() { + if info.Kind == kind && info.Available { + return info.Capabilities, true + } + } + return proto.AgentKindCapabilities{}, false +} diff --git a/apps/parsar-daemon/internal/dispatch/capability_admission_test.go b/apps/daemon/internal/dispatch/capability_admission_test.go similarity index 94% rename from apps/parsar-daemon/internal/dispatch/capability_admission_test.go rename to apps/daemon/internal/dispatch/capability_admission_test.go index ebc1b9442..0a9fde120 100644 --- a/apps/parsar-daemon/internal/dispatch/capability_admission_test.go +++ b/apps/daemon/internal/dispatch/capability_admission_test.go @@ -6,10 +6,10 @@ import ( "sync/atomic" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) func TestSteeringUsesAdmittedDeclarationAndDoesNotReplayUnsupportedImplementation(t *testing.T) { diff --git a/apps/parsar-daemon/internal/dispatch/durable_only_test.go b/apps/daemon/internal/dispatch/durable_only_test.go similarity index 94% rename from apps/parsar-daemon/internal/dispatch/durable_only_test.go rename to apps/daemon/internal/dispatch/durable_only_test.go index 0385507b9..ffb5e4c98 100644 --- a/apps/parsar-daemon/internal/dispatch/durable_only_test.go +++ b/apps/daemon/internal/dispatch/durable_only_test.go @@ -5,8 +5,8 @@ import ( "sync/atomic" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type durableOnlyExecutor struct { diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go new file mode 100644 index 000000000..3b0cb4b91 --- /dev/null +++ b/apps/daemon/internal/dispatch/environment.go @@ -0,0 +1,23 @@ +package dispatch + +import ( + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if err := req.ValidateProgrammaticToolCallingDisable(caps.ProgrammaticToolCallingDisable.IsSupported()); err != nil { + return err + } + if err := req.ValidateToolSearch(caps.ToolSearch.IsSupported()); err != nil { + return err + } + if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment.IsSupported()) { + return errors.New("engine does not support this local Environment configuration") + } + if req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported() { + return errors.New("engine does not support execution environment none") + } + return validateMCPHTTP(req, caps) +} diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go new file mode 100644 index 000000000..982f4a0af --- /dev/null +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -0,0 +1,77 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "claude_code", DisableExecutionEnvironment: true})) + if err == nil || called { + t.Fatal("unsupported engine was started", err) + } + frames := h.sender.snapshot() + if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal(frames) + } +} + +func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { + for _, available := range []bool{false, true} { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, errors.New("controlled factory stop") + }) + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk", DisableExecutionEnvironment: true})) + if called != available { + t.Fatalf("factory called=%t, available=%t", called, available) + } + } +} + +func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { + for _, mode := range []string{"unsupported", "unavailable", "none conflict", "supported"} { + t.Run(mode, func(t *testing.T) { + h := localPreparationHarness(t) + defer h.router.Shutdown(context.Background()) + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", + Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported")})}, + harnessconfig.Configuration{}, func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + called = true + if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { + t.Error("local descriptor lost before factory") + } + return nil, errors.New("controlled factory stop") + }) + req := preparationRequest().Configuration + req.AgentKind = "codex" + req.DisableExecutionEnvironment = mode == "none conflict" + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "local", req)) + if called != (mode == "supported") { + t.Fatalf("unexpected factory call for %s", mode) + } + frames := h.sender.snapshot() + if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal("missing terminal error frames") + } + }) + } +} diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go new file mode 100644 index 000000000..90544c9a7 --- /dev/null +++ b/apps/daemon/internal/dispatch/executor.go @@ -0,0 +1,380 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const executorIdleCapacity = 16 + +type executorState struct { + capabilities proto.AgentKindCapabilities + id, sessionID, environmentID, stateKey string + fingerprint [32]byte + native agent.Executor + nativeID string + ctx context.Context + cancel context.CancelFunc + admission *preparationState + run *sessionState + preparing bool + invalid bool + closeDone chan struct{} + closeReason string + closeErr error + timer *time.Timer + idleLease uint64 +} + +func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { + req.RunID, req.Input = "", nil + req.AgentSessionID = "" + req.RequireExistingNativeSession = false + req.ReleaseOnCompletion = false + data, err := json.Marshal(req) + return sha256.Sum256(data), err +} + +func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error { + req := input.Configuration + if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || req.AgentStateKey != "agents-api-"+input.SessionID || !req.StrictResume { + return r.rejectPreparation(env, "invalid_configuration") + } + caps, available := r.availableCapabilities(req.AgentKind) + if !available { + return r.rejectPreparation(env, "resource_unavailable") + } + factory, err := r.registry.ResolveExecutor(req.AgentKind) + if err != nil || !caps.Preparation.IsSupported() { + return r.rejectPreparation(env, "unsupported_preparation") + } + req, err = r.localWorkspace.Configure(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + if validateExecutionEnvironment(req, caps) != nil || len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported() { + return r.rejectPreparation(env, "unsupported_configuration") + } + fingerprint, err := executorFingerprint(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + encoded, err := json.Marshal(input) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + requestFingerprint := sha256.Sum256(encoded) + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return ErrRouterClosed + } + if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") + } + r.prunePreparationsLocked() + if old := r.preparationRequests[env.ID]; old != nil { + matches, status := old.fingerprint == requestFingerprint, old.status + r.mu.Unlock() + if !matches { + return r.rejectPreparation(env, "request_conflict") + } + r.publishPreparation(old, status) + return nil + } + if len(r.preparations) >= preparationRecords { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + active := 0 + for _, candidate := range r.executors { + if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { + r.mu.Unlock() + return r.rejectPreparation(env, "session_binding_conflict") + } + } + for _, owner := range r.executors { + if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { + active++ + } + } + if active >= preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + owner := r.executors[input.SessionID] + reused := owner != nil + if owner != nil { + code := "" + switch { + case owner.environmentID != req.EnvironmentID() || owner.fingerprint != fingerprint: + code = "configuration_conflict" + case owner.invalid: + code = "executor_cleanup_unconfirmed" + case owner.preparing || owner.admission != nil || owner.run != nil: + code = "resource_unavailable" + case req.AgentSessionID != owner.nativeID: + code = "native_session_conflict" + case req.RequireExistingNativeSession && owner.nativeID == "": + code = "history_unavailable" + } + if code != "" { + r.mu.Unlock() + return r.rejectPreparation(env, code) + } + if owner.timer != nil { + owner.timer.Stop() + } + owner.idleLease++ + } else { + if len(r.executors) >= executorIdleCapacity+preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "executor_capacity") + } + ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} + r.executors[input.SessionID] = owner + r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) + } + operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) + p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} + state := "preparing" + if reused { + state = "ready" + } + p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), ExecutorID: owner.id, Revision: 1, State: state, Reused: reused, ExpiresAt: p.deadline.UnixMilli()} + owner.admission = p + r.preparations[p.status.Handle], r.preparationRequests[env.ID] = p, p + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + if !reused { + r.shutdownWG.Add(1) + } + status := p.status + r.mu.Unlock() + if reused { + r.publishPreparation(p, status) + } else { + go r.prepareExecutor(p, req, factory) + } + return nil +} + +func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory) { + defer r.shutdownWG.Done() + owner := p.executor + started := time.Now() + r.mu.Lock() + initial := p.status + r.mu.Unlock() + if !r.sendPreparation(p.requestID, p.trace, initial) { + r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) + } + var native agent.Executor + var err error + if owner.ctx.Err() == nil { + req, err = r.localWorkspace.Prepare(owner.ctx, req) + if err == nil && owner.ctx.Err() == nil { + native, err = factory(owner.ctx, req) + } + } else { + err = owner.ctx.Err() + } + r.mu.Lock() + owner.native, owner.preparing = native, false + r.log.Info("executor native_prepare", "executor_id", owner.id, "session_id", owner.sessionID, "duration_ms", time.Since(started).Milliseconds(), "success", err == nil && native != nil) + ready := native != nil && err == nil && !owner.invalid && !r.closed && r.suspension == nil && p.status.State == "preparing" && p.ctx.Err() == nil + p.busy = false + if ready { + p.status.State, p.status.Revision = "ready", p.status.Revision+1 + } else { + owner.invalid = true + owner.closeReason = "preparation_failed" + if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + p.timer.Stop() + } + status := p.status + r.mu.Unlock() + if !ready { + closeErr := r.closeExecutor(owner) + r.mu.Lock() + p.owns, p.closeErr = closeErr != nil, closeErr + if closeErr != nil { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "executor_cleanup_unconfirmed", p.status.Revision+1 + } + status = p.status + r.mu.Unlock() + } + if !r.sendPreparation(p.requestID, p.trace, status) && ready { + r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) + } +} + +func (r *Router) abandonExecutorAdmission(p *preparationState, state, code string, publish bool) { + r.mu.Lock() + owner := p.executor + if p.handoff != nil || p.status.State == "started" { + status := p.status + r.mu.Unlock() + if publish && state == "released" { + r.publishPreparation(p, status) + } + return + } + if p.status.State == "preparing" || p.status.State == "ready" { + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.timer.Stop() + p.cancel() + p.owns = false + if owner.admission == p { + owner.admission = nil + } + if owner.preparing { + owner.invalid = true + owner.cancel() + } else if !owner.invalid { + r.scheduleExecutorIdleLocked(owner) + } + } + status := p.status + r.mu.Unlock() + if publish { + r.publishPreparation(p, status) + } +} + +func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { + if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil { + return + } + if owner.timer != nil { + owner.timer.Stop() + } + idle := 0 + for _, candidate := range r.executors { + if candidate != owner && candidate.run == nil && candidate.admission == nil && !candidate.preparing { + idle++ + } + } + if idle >= executorIdleCapacity { + owner.invalid = true + owner.closeReason = "idle_capacity" + r.shutdownWG.Add(1) + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() + return + } + owner.idleLease++ + lease := owner.idleLease + r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) + owner.timer = time.AfterFunc(r.idleTimeout, func() { + r.mu.Lock() + if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { + r.mu.Unlock() + return + } + owner.invalid = true + owner.closeReason = "idle_expired" + r.shutdownWG.Add(1) + r.mu.Unlock() + defer r.shutdownWG.Done() + _ = r.closeExecutor(owner) + }) +} + +// Close failures keep the exact owner; a later call retries only settled failures. +func (r *Router) closeExecutor(owner *executorState) error { + r.mu.Lock() + owner.invalid = true + if owner.closeReason == "" { + owner.closeReason = "invalidated" + } + if owner.timer != nil { + owner.timer.Stop() + } + if owner.preparing { + owner.cancel() + r.mu.Unlock() + return errors.New("executor preparation is still settling") + } + if done := owner.closeDone; done != nil { + select { + case <-done: + if owner.closeErr == nil { + r.mu.Unlock() + return nil + } + default: + r.mu.Unlock() + <-done + r.mu.Lock() + err := owner.closeErr + r.mu.Unlock() + return err + } + } + done := make(chan struct{}) + owner.closeDone = done + native := owner.native + r.mu.Unlock() + var err error + if native != nil { + ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) + err = native.Close(ctx) + cancel() + } + r.mu.Lock() + owner.closeErr = err + if err == nil { + owner.cancel() + } + r.log.Info("executor owner_closed", "executor_id", owner.id, "session_id", owner.sessionID, "confirmed", err == nil, "reason", owner.closeReason) + if err == nil && owner.run == nil && r.executors[owner.sessionID] == owner { + delete(r.executors, owner.sessionID) + } + close(done) + r.mu.Unlock() + return err +} + +func (r *Router) closeIdleExecutorsLocked() []*executorState { + var owners []*executorState + for _, owner := range r.executors { + owner.invalid = true + owner.closeReason = "shutdown" + if owner.preparing { + owner.cancel() + } + if owner.timer != nil { + owner.timer.Stop() + } + if owner.admission != nil && owner.run == nil { + p := owner.admission + p.cancel() + p.timer.Stop() + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 + p.owns = false + owner.admission = nil + } + if !owner.preparing && owner.run == nil { + r.shutdownWG.Add(1) + owners = append(owners, owner) + } + } + return owners +} + +func (r *Router) closeIdleExecutors(owners []*executorState) { + for _, owner := range owners { + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() + } +} diff --git a/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go rename to apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index 8fcc8f042..b99a1ef84 100644 --- a/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type receiptCancelSender struct { diff --git a/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/executor_handoff_test.go rename to apps/daemon/internal/dispatch/executor_handoff_test.go index 38d3d938a..b0fc793fa 100644 --- a/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -1,6 +1,6 @@ package dispatch -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type terminalHandoffSender struct { diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go new file mode 100644 index 000000000..66ec59fba --- /dev/null +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -0,0 +1,319 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type reusableExecutor struct { + starts chan *reusableTurn + closes atomic.Int32 + mu sync.Mutex + closeErr error + startErr error +} + +func (e *reusableExecutor) Close(context.Context) error { + e.closes.Add(1) + e.mu.Lock() + defer e.mu.Unlock() + return e.closeErr +} +func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if e.startErr != nil { + return nil, e.startErr + } + t := &reusableTurn{id: id, out: out, settled: make(chan struct{})} + e.starts <- t + return t, nil +} + +type reusableTurn struct { + id string + out chan<- proto.Envelope + settled chan struct{} + once sync.Once + cancels atomic.Int32 +} + +func (t *reusableTurn) finish() { + t.once.Do(func() { + frame, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()) + t.out <- frame + close(t.out) + close(t.settled) + }) +} +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"}} +} +func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: true}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func executorRequest() proto.ExecutionPreparePayload { + return proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} +} +func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { + t.Helper() + calls := &atomic.Int32{} + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("ordinary factory is forbidden") + }) + reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + calls.Add(1) + return owner, nil + }) + sender := &recSender{} + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: idle}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + owner.mu.Lock() + owner.closeErr = nil + owner.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return router, sender, calls +} +func executorAdmission(t *testing.T, r *dispatch.Router, s *recSender, key string, req proto.ExecutionPreparePayload) proto.PreparationStatusPayload { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, key, req)); err != nil { + t.Fatal(err) + } + return waitPreparationStatus(t, s, key, "ready", "") +} +func startExecutorTurn(t *testing.T, r *dispatch.Router, s *recSender, key, id string, p proto.PreparationStatusPayload) { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, key, proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: id, Input: proto.TextInput("input")})); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, s, key, "started", "") +} +func TestExecutorRetainsOwnerAcrossIndependentTurns(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 2)} + r, s, calls := executorRouter(t, owner, time.Minute) + first := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", first) + turn := <-owner.starts + turn.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "first Turn settled") + if owner.closes.Load() != 0 || turn.cancels.Load() != 0 { + t.Fatal("normal completion tore down or cancelled the executor") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: first.Handle})) + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.RequireExistingNativeSession = true + second := executorAdmission(t, r, s, "second", req) + if second.Handle == first.Handle || second.ExecutorID != first.ExecutorID || !second.Reused || calls.Load() != 1 { + t.Fatalf("reuse identities: first=%+v second=%+v calls=%d", first, second, calls.Load()) + } + startExecutorTurn(t, r, s, "second", "two", second) + next := <-owner.starts + _ = turn.Cancel(t.Context()) + if next.cancels.Load() != 0 { + t.Fatal("old Turn cancellation reached its successor") + } + next.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "second Turn settled") + if owner.closes.Load() != 0 { + t.Fatal("executor closed between Turns") + } +} +func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "run", p) + turn := <-owner.starts + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return len(cancellationAcks(s)) == 1 }, "cancellation receipt") + ack := cancellationAcks(s)[0] + if !ack.Applied || ack.Outcome == nil || turn.cancels.Load() != 1 || owner.closes.Load() != 0 { + t.Fatalf("cancel=%+v closes=%d", ack, owner.closes.Load()) + } +} +func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, calls := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", p) + (<-owner.starts).finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "Turn settlement") + for _, kind := range []string{"configuration", "native"} { + req := executorRequest() + if kind == "configuration" { + req.Configuration.AgentOptions = map[string]any{"model": "changed"} + } else { + req.Configuration.AgentSessionID = "other-native" + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, kind, req)); err == nil { + t.Fatalf("accepted changed %s", kind) + } + } + if calls.Load() != 1 || owner.closes.Load() != 0 { + t.Fatal("conflict replaced executor") + } +} +func TestExecutorIdleExpiryClosesRetainedOwner(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, owner, 30*time.Millisecond) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", p) + (<-owner.starts).finish() + waitFor(t, func() bool { return owner.closes.Load() == 1 }, "idle executor close") +} +func TestExecutorPreInputFailureConfirmsCloseBeforeRetrySignal(t *testing.T) { + for _, unconfirmed := range []bool{false, true} { + t.Run(map[bool]string{false: "closed", true: "unconfirmed"}[unconfirmed], func(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1), startErr: errors.New("native exited")} + if unconfirmed { + owner.closeErr = errors.New("cleanup unknown") + } + r, s, _ := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "first", proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: "run", Input: proto.TextInput("input")})) + status := waitPreparationStatus(t, s, "first", "rejected", "") + want := "executor_unavailable" + if unconfirmed { + want = "executor_cleanup_unconfirmed" + } + if status.ErrorCode != want || owner.closes.Load() == 0 { + t.Fatalf("status=%+v closes=%d", status, owner.closes.Load()) + } + if unconfirmed { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "retry", executorRequest())); err == nil { + t.Fatal("replaced unconfirmed owner") + } + } + }) + } +} + +func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, *recSender) { + t.Helper() + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("unexpected legacy factory") + }) + registry.RegisterExecutor("reusable", factory) + sender := &recSender{} + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, IdleTimeout: time.Minute}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return router, sender +} +func poolRequest(id string) proto.ExecutionPreparePayload { + req := executorRequest() + req.SessionID = id + req.Configuration.AgentStateKey = "agents-api-" + id + return req +} + +func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { + var mu sync.Mutex + var owners []*reusableExecutor + r, s := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + e := &reusableExecutor{} + mu.Lock() + owners = append(owners, e) + mu.Unlock() + return e, nil + }) + for i := 0; i < 17; i++ { + id := fmt.Sprint("idle-", i) + ready := executorAdmission(t, r, s, id, poolRequest(id)) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + } + waitFor(t, func() bool { + mu.Lock() + defer mu.Unlock() + var closed int32 + for _, e := range owners { + closed += e.closes.Load() + } + return closed == 1 + }, "idle capacity eviction") + for i := 0; i < 4; i++ { + id := fmt.Sprint("active-", i) + executorAdmission(t, r, s, id, poolRequest(id)) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", poolRequest("overflow"))); err == nil { + t.Fatal("active capacity exceeded") + } +} + +func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { + e := &reusableExecutor{} + r, s, calls := executorRouter(t, e, time.Minute) + executorAdmission(t, r, s, "one", executorRequest()) + req := executorRequest() + req.SessionID = "another" + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "scope", req)); err == nil { + t.Fatal("two sessions shared native state") + } + if calls.Load() != 1 { + t.Fatal("mismatched session invoked native preparation") + } +} + +func TestExecutorRejectsOutputFromAnotherTurn(t *testing.T) { + e := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, e, time.Minute) + p := executorAdmission(t, r, s, "one", executorRequest()) + startExecutorTurn(t, r, s, "one", "current", p) + turn := <-e.starts + wrong, _ := proto.NewEnvelope(proto.TypeDelta, "old-turn", proto.DeltaPayload{Delta: "late"}) + turn.out <- wrong + turn.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "invalid native event cleanup") + if e.closes.Load() != 1 { + t.Fatal("invalid event left executor reusable") + } + for _, frame := range s.snapshot() { + if frame.ID == "old-turn" { + t.Fatal("old Turn output escaped") + } + } +} + +func (*reusableTurn) SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error { + return agent.ErrSteeringRejected +} diff --git a/apps/parsar-daemon/internal/dispatch/export_test.go b/apps/daemon/internal/dispatch/export_test.go similarity index 100% rename from apps/parsar-daemon/internal/dispatch/export_test.go rename to apps/daemon/internal/dispatch/export_test.go diff --git a/apps/daemon/internal/dispatch/functions.go b/apps/daemon/internal/dispatch/functions.go new file mode 100644 index 000000000..89ee63a41 --- /dev/null +++ b/apps/daemon/internal/dispatch/functions.go @@ -0,0 +1,74 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) error { + var result proto.FunctionResultPayload + if err := env.DecodePayload(&result); err != nil { + return err + } + if env.ID == "" || result.CallID == "" || result.DeliveryID == "" { + return errors.New("function result requires run, call and delivery identities") + } + if err := result.ValidateContent(); err != nil { + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "invalid_result", err.Error()) + } + decision := result + decision.DeliveryID = "" + encoded, err := json.Marshal(decision) + if err != nil { + return err + } + fingerprint := sha256.Sum256(encoded) + // Scope receipt replay to both identities, even when native call IDs repeat across Runs. + kind := proto.TypeFunctionResult + "\x00" + result.CallID + if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, result.DeliveryID, kind, fingerprint); handled { + return err + } + 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") + } + if finishOperation != nil { + defer finishOperation() + var stop context.CancelFunc + ctx, stop = r.shutdownContext(ctx) + defer stop() + } + 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 { + 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 { + code := "runtime_error" + if errors.Is(err, agent.ErrUnsupportedOperation) { + code = "contract_violation" + } + if errors.Is(err, agent.ErrUnknownFunctionCall) { + code = "not_pending" + } + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, code, "function result was not applied") + } + r.rememberAppliedInteractionDecision(env.ID, kind, fingerprint) + return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, true, "", "") +} diff --git a/apps/parsar-daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go similarity index 94% rename from apps/parsar-daemon/internal/dispatch/functions_native_test.go rename to apps/daemon/internal/dispatch/functions_native_test.go index 7c222e786..036ea1c8d 100644 --- a/apps/parsar-daemon/internal/dispatch/functions_native_test.go +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -16,11 +16,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type nativeFunctionSender chan proto.Envelope diff --git a/apps/daemon/internal/dispatch/functions_test.go b/apps/daemon/internal/dispatch/functions_test.go new file mode 100644 index 000000000..59be84409 --- /dev/null +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -0,0 +1,181 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type functionSession struct { + *fakeSession + mu sync.Mutex + calls int +} + +func (s *functionSession) SubmitFunctionResult(_ context.Context, p proto.FunctionResultPayload) error { + s.mu.Lock() + defer s.mu.Unlock() + if p.CallID != "call" || s.calls != 0 { + return agent.ErrUnknownFunctionCall + } + s.calls++ + return nil +} +func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { + reg := agent.NewRegistry() + sender := &recSender{} + sessions := map[string]*functionSession{} + reg.RegisterKind(proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, p proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + s := &functionSession{fakeSession: &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}} + sessions[p.RunID] = s + return s, nil + }) + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + if err != nil { + t.Fatal(err) + } + defer router.Shutdown(context.Background()) + for _, id := range []string{"one", "two"} { + env, _ := proto.NewEnvelope(proto.TypePromptRequest, id, proto.PromptRequestPayload{AgentKind: "function-test", Input: proto.TextInput("lookup"), FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { + t.Helper() + env, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: call, Success: true, Content: functionResultContent(text), DeliveryID: delivery}) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + last := frames[len(frames)-1] + var ack proto.InteractionDecisionAckPayload + if err := last.DecodePayload(&ack); err != nil || last.Type != proto.TypeInteractionDecisionAck || last.ID != run || ack.DeliveryID != delivery { + t.Fatal(last, err) + } + return ack + } + if a := submit("missing", "call", "answer", "a"); a.Applied || a.ErrorCode != "not_pending" { + t.Fatal(a) + } + if a := submit("one", "missing", "answer", "b"); a.Applied || a.ErrorCode != "not_pending" { + t.Fatal(a) + } + + invalid, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", DeliveryID: "invalid", Content: []proto.InputContent{{Type: "input_audio"}}}) + if err := router.Handle(t.Context(), invalid); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + var invalidAck proto.InteractionDecisionAckPayload + _ = frames[len(frames)-1].DecodePayload(&invalidAck) + if invalidAck.Applied || invalidAck.ErrorCode != "invalid_result" { + t.Fatal(invalidAck) + } + // A lost receipt may be retried without writing the native result twice. + sender.mu.Lock() + sender.failNow = true + sender.mu.Unlock() + first, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "lost"}) + if err := router.Handle(t.Context(), first); err == nil { + t.Fatal("receipt send failure was hidden") + } + if a := submit("one", "call", "answer", "retry"); !a.Applied { + t.Fatal(a) + } + if a := submit("one", "call", "changed", "conflict"); a.Applied || a.ErrorCode != "decision_conflict" { + t.Fatal(a) + } + + for _, mutation := range []string{"image", "order", "success"} { + result := proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: mutation} + switch mutation { + case "image": + other := "https://example.com/other.png" + result.Content[1].ImageURL = &other + case "order": + result.Content[0], result.Content[1] = result.Content[1], result.Content[0] + case "success": + result.Success = false + } + env, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", result) + if err := router.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + frames := sender.snapshot() + var ack proto.InteractionDecisionAckPayload + _ = frames[len(frames)-1].DecodePayload(&ack) + if ack.Applied || ack.ErrorCode != "decision_conflict" { + t.Fatal(mutation, ack) + } + } + if a := submit("two", "call", "second answer", "other-run"); !a.Applied { + t.Fatal(a) + } + for _, s := range sessions { + s.mu.Lock() + count := s.calls + s.mu.Unlock() + if count != 1 { + t.Fatal(count) + } + } +} + +func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { + reg := agent.NewRegistry() + called := false + reg.RegisterKind(proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + sender := &recSender{} + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) + defer router.Shutdown(context.Background()) + env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + if err := router.Handle(t.Context(), env); err == nil || called { + t.Fatal("unsupported engine silently ignored tools", err) + } +} + +func functionResultContent(text string) []proto.InputContent { + picture := image.NewRGBA(image.Rect(0, 0, 1, 1)) + picture.Set(0, 0, color.RGBA{R: 255, A: 255}) + var encoded bytes.Buffer + if err := png.Encode(&encoded, picture); err != nil { + panic(err) + } + imageURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()) + after := "AFTER-IMAGE" + return []proto.InputContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &imageURL}, {Type: "input_text", Text: &after}} +} + +func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { + reg := agent.NewRegistry() + called := false + reg.RegisterKind(proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: &recSender{}}) + defer router.Shutdown(context.Background()) + for _, search := range []bool{false, true} { + env, _ := proto.NewEnvelope(proto.TypePromptRequest, "discovery", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) + if err := router.Handle(t.Context(), env); err == nil || called { + t.Fatal("deferred definitions reached an eager-only adapter", err) + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go b/apps/daemon/internal/dispatch/interaction_decisions.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/interaction_decisions.go rename to apps/daemon/internal/dispatch/interaction_decisions.go index 7bef0a732..77ed0fd8c 100644 --- a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go +++ b/apps/daemon/internal/dispatch/interaction_decisions.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (r *Router) handlePermissionDecision(ctx context.Context, env proto.Envelope) error { diff --git a/apps/parsar-daemon/internal/dispatch/local_directory.go b/apps/daemon/internal/dispatch/local_directory.go similarity index 77% rename from apps/parsar-daemon/internal/dispatch/local_directory.go rename to apps/daemon/internal/dispatch/local_directory.go index fe61bdac7..02c1a4fa4 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory.go +++ b/apps/daemon/internal/dispatch/local_directory.go @@ -3,8 +3,8 @@ package dispatch import ( "context" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type localDirectoryPreparation struct{} diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go similarity index 94% rename from apps/parsar-daemon/internal/dispatch/local_directory_test.go rename to apps/daemon/internal/dispatch/local_directory_test.go index fb2caa3ac..b0363bf24 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -11,11 +11,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "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/google/uuid" ) diff --git a/apps/daemon/internal/dispatch/mcp_http.go b/apps/daemon/internal/dispatch/mcp_http.go new file mode 100644 index 000000000..c0cbe9add --- /dev/null +++ b/apps/daemon/internal/dispatch/mcp_http.go @@ -0,0 +1,37 @@ +package dispatch + +import ( + "errors" + "net/url" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Validate combined placement and authentication before factory selection. +func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if req.MCPHTTPServers == nil { + return nil + } + for _, server := range *req.MCPHTTPServers { + if err := server.ValidateConnectionOrigin(req); err != nil { + return err + } + if !caps.MCPHTTPTools.IsSupported() { + return errors.New("engine does not support HTTP MCP") + } + if server.Required && !caps.MCPHTTPRequired.IsSupported() { + return errors.New("engine does not support required HTTP MCP initialization") + } + if server.BearerToken == nil { + continue + } + if !caps.MCPHTTPTools.IsSupported() || !caps.MCPHTTPBearerAuth.IsSupported() { + return errors.New("engine does not support authenticated HTTP MCP") + } + endpoint, err := url.Parse(server.ServerURL) + if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" { + return errors.New("authenticated HTTP MCP requires HTTPS") + } + } + return nil +} diff --git a/apps/daemon/internal/dispatch/mcp_http_test.go b/apps/daemon/internal/dispatch/mcp_http_test.go new file mode 100644 index 000000000..45ae2fe80 --- /dev/null +++ b/apps/daemon/internal/dispatch/mcp_http_test.go @@ -0,0 +1,137 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { + for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { + t.Run(mode, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + token := "synthetic-private-token" + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} + caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) + switch mode { + case "claude", "claude old peer", "claude local": + req.AgentKind = "claude_sdk" + caps.MCPHTTPBearerAuth = proto.CapabilityFromBool(mode != "claude old peer") + if mode == "claude local" { + req.DisableExecutionEnvironment = false + } + case "required", "required old peer", "optional old peer": + servers[0].Required = mode != "optional old peer" + servers[0].BearerToken = nil + caps.MCPHTTPRequired = proto.CapabilityFromBool(mode == "required") + case "no bearer capability", "credential-free", "product": + caps.MCPHTTPBearerAuth = proto.CapabilityUnsupported + case "no MCP capability": + caps.MCPHTTPTools = proto.CapabilityUnsupported + case "no none capability": + caps.EnvironmentNone = proto.CapabilityUnsupported + case "local": + req.DisableExecutionEnvironment = false + case "other engine": + req.AgentKind = "other" + case "HTTP": + servers[0].ServerURL = "http://tools.example/mcp" + } + if mode == "credential-free" { + servers[0].BearerToken = nil + } + if mode == "product" { + req.MCPHTTPServers, req.DisableExecutionEnvironment = nil, false + } + called := false + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, + harnessconfig.Configuration{}, func(_ context.Context, got proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { + called = true + if mode == "required" && !(*got.MCPHTTPServers)[0].Required { + t.Error("required initialization lost before adapter") + } + if (mode == "supported" || mode == "claude") && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != token) { + t.Error("token lost before adapter") + } + return nil, errors.New("controlled factory stop") + }) + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) + if called != (mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { + t.Fatal("wrong factory admission") + } + frames := h.sender.snapshot() + raw, _ := json.Marshal(frames) + if err == nil || strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) || len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { + t.Fatal("terminal rejection missing or exposed credential") + } + }) + } +} + +func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { + for _, mode := range []string{"anonymous", "bearer", "required", "empty declaration", "no declaration", "environment anonymous", "environment bearer", "environment required", "environment missing capability"} { + t.Run(mode, func(t *testing.T) { + h := localPreparationHarness(t) + defer h.router.Shutdown(context.Background()) + req := preparationRequest() + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} + req.Configuration.MCPHTTPServers = &servers + if strings.HasPrefix(mode, "environment") { + servers[0].ConnectionOrigin = "environment" + req.Configuration.LocalEnvironment.NetworkAccess = "enabled" + } + if strings.Contains(mode, "bearer") { + token := "synthetic-private-token" + servers[0].BearerToken = &token + } + if strings.Contains(mode, "required") { + servers[0].Required = true + } + if mode == "empty declaration" { + servers = []proto.MCPHTTPServer{} + } + if mode == "no declaration" { + req.Configuration.MCPHTTPServers = nil + } + entered := make(chan struct{}, 1) + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + t.Error("ordinary factory called") + return nil, errors.New("unexpected") + }) + h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + entered <- struct{}{} + return nil, errors.New("controlled stop") + }) + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local-mcp", req)) + allowed := mode == "no declaration" || mode == "empty declaration" || strings.HasPrefix(mode, "environment") && mode != "environment missing capability" + if (err == nil) != allowed { + t.Fatal("wrong preparation admission", err) + } + if allowed { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("factory not called") + } + waitPreparationStatus(t, h.sender, "local-mcp", "failed", "") + } else { + select { + case <-entered: + t.Fatal("rejected request reached factory") + default: + } + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/native_file_results_test.go b/apps/daemon/internal/dispatch/native_file_results_test.go similarity index 93% rename from apps/parsar-daemon/internal/dispatch/native_file_results_test.go rename to apps/daemon/internal/dispatch/native_file_results_test.go index e0f05f9e6..dcc8b83bf 100644 --- a/apps/parsar-daemon/internal/dispatch/native_file_results_test.go +++ b/apps/daemon/internal/dispatch/native_file_results_test.go @@ -2,8 +2,8 @@ package dispatch import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" "io/fs" "testing" diff --git a/apps/parsar-daemon/internal/dispatch/optional_interactions_test.go b/apps/daemon/internal/dispatch/optional_interactions_test.go similarity index 88% rename from apps/parsar-daemon/internal/dispatch/optional_interactions_test.go rename to apps/daemon/internal/dispatch/optional_interactions_test.go index 7895b1774..78a7258c9 100644 --- a/apps/parsar-daemon/internal/dispatch/optional_interactions_test.go +++ b/apps/daemon/internal/dispatch/optional_interactions_test.go @@ -1,14 +1,14 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) // Wrapping only Cancel proves that no responder stubs are required for a Session. diff --git a/apps/parsar-daemon/internal/dispatch/output.go b/apps/daemon/internal/dispatch/output.go similarity index 95% rename from apps/parsar-daemon/internal/dispatch/output.go rename to apps/daemon/internal/dispatch/output.go index f619ff08b..e0e96dca9 100644 --- a/apps/parsar-daemon/internal/dispatch/output.go +++ b/apps/daemon/internal/dispatch/output.go @@ -3,8 +3,8 @@ package dispatch import ( "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // pump forwards every Envelope the session writes onto out to the diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go new file mode 100644 index 000000000..e77761f09 --- /dev/null +++ b/apps/daemon/internal/dispatch/preparation.go @@ -0,0 +1,307 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const preparationCapacity = 4 +const preparationRecords = 64 + +// All mutable fields are protected by Router.mu. owns includes resources whose +// cancellation is underway; a slow close cannot bypass the capacity bound. +type preparationState struct { + capabilities proto.AgentKindCapabilities + executor *executorState + requestID string + trace string + fingerprint [32]byte + startFingerprint [32]byte + status proto.PreparationStatusPayload + deadline time.Time + timer *time.Timer + ctx context.Context + cancel context.CancelFunc + prepared agent.Prepared + stateKey string + environmentID string + busy bool + owns bool + closeErr error + workspaceReadOnly bool + handoff *preparedHandoff +} + +func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) error { + var input proto.ExecutionPreparePayload + if env.DecodePayload(&input) != nil || strings.TrimSpace(env.ID) == "" { + return r.rejectPreparation(env, "invalid_request") + } + req := input.Configuration + if !req.WorkspaceReadOnly { + return r.handleExecutorPrepare(ctx, env, input) + } + caps, available := r.availableCapabilities(req.AgentKind) + if !available { + return r.rejectPreparation(env, "resource_unavailable") + } + prepare, err := r.registry.ResolvePreparation(req.AgentKind) + if err != nil || !caps.Preparation.IsSupported() { + return r.rejectPreparation(env, "unsupported_preparation") + } + if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req)) { + return r.rejectPreparation(env, "unsupported_read_preparation") + } + if req, err = r.localWorkspace.Configure(req); err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { + return r.rejectPreparation(env, "invalid_configuration") + } + if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { + return r.rejectPreparation(env, "unsupported_configuration") + } + if req.LocalEnvironment != nil && req.WorkspaceReadOnly { + prepare = prepareLocalDirectory + } + encoded, err := json.Marshal(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + fingerprint := sha256.Sum256(encoded) + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") + } + r.prunePreparationsLocked() + if old := r.preparationRequests[env.ID]; old != nil { + status := old.status + matches := old.fingerprint == fingerprint + r.mu.Unlock() + if !matches { + return r.rejectPreparation(env, "request_conflict") + } + r.publishPreparation(old, status) + return nil + } + owned := 0 + for _, p := range r.preparations { + if p.owns { + owned++ + } + } + if owned >= preparationCapacity || len(r.preparations) >= preparationRecords { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} + r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.prepareExecution(p, req, prepare) + return nil +} + +func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { + defer r.shutdownWG.Done() + if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } + var prepared agent.Prepared + var err error + if p.ctx.Err() == nil { + prepared, err = prepare(p.ctx, req) + } else { + err = p.ctx.Err() + } + if err == nil && prepared != nil && !p.workspaceReadOnly { + if _, ok := prepared.(agent.PreparedCancellation); !ok { + err = errors.New("executable preparation requires cross-transfer cancellation") + } + } + r.mu.Lock() + p.busy = false + p.prepared = prepared + ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed + if ready { + p.status.State, p.status.Revision = "ready", p.status.Revision+1 + } else if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + status := p.status + if !ready { + p.busy = true + p.cancel() + p.timer.Stop() + } + r.mu.Unlock() + if !ready { + r.closePreparationResource(p) + if p.workspaceReadOnly { + return + } + r.mu.Lock() + status = p.status + r.mu.Unlock() + } + if !r.sendPreparation(p.requestID, p.trace, status) && ready { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } +} + +func (r *Router) handleExecutionRelease(_ context.Context, env proto.Envelope) error { + var input proto.ExecutionReleasePayload + if env.DecodePayload(&input) != nil || input.Handle == "" { + return r.rejectPreparation(env, "invalid_release") + } + r.mu.Lock() + p := r.preparations[input.Handle] + valid := p != nil && p.requestID == env.ID + r.mu.Unlock() + if !valid { + return r.rejectPreparation(env, "unknown_preparation") + } + r.releasePreparation(p, "released", "", true, true) + return nil +} + +func (r *Router) releasePreparation(p *preparationState, state, code string, publish, retryHandoff bool) { + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return + } + if p.executor != nil { + r.mu.Unlock() + r.abandonExecutorAdmission(p, state, code, publish) + return + } + if p.handoff != nil { + if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { + if state == "expired" && p.handoff.published { + r.mu.Unlock() + return + } + switch p.status.State { + case "preparing", "ready", "starting", "started": + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.timer.Stop() + } + r.claimPreparedReleaseLocked(active, true, "", retryHandoff) + status := p.status + r.mu.Unlock() + if publish { + r.publishPreparation(p, status) + } + return + } + } + closeResource := false + switch p.status.State { + case "preparing", "ready", "starting": + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.cancel() + p.timer.Stop() + } + if p.owns && !p.busy { + if p.workspaceReadOnly && state == "released" && p.status.ErrorCode == "cleanup_unconfirmed" { + p.status.State, p.status.ErrorCode, p.status.Revision = state, "", p.status.Revision+1 + } + p.busy = true + closeResource = true + r.shutdownWG.Add(1) + } + status := p.status + settled := !p.owns && !p.busy + r.mu.Unlock() + if closeResource { + go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() + } + if publish && (!p.workspaceReadOnly || settled) { + r.publishPreparation(p, status) + } +} + +func (r *Router) prunePreparationsLocked() { + var oldest *preparationState + for handle, p := range r.preparations { + if p.owns { + continue + } + if time.Now().After(p.deadline) { + delete(r.preparations, handle) + delete(r.preparationRequests, p.requestID) + } else if oldest == nil || p.deadline.Before(oldest.deadline) { + oldest = p + } + } + if len(r.preparations) >= preparationRecords && oldest != nil { + delete(r.preparations, oldest.status.Handle) + delete(r.preparationRequests, oldest.requestID) + } +} + +func (r *Router) publishPreparation(p *preparationState, status proto.PreparationStatusPayload) { + r.mu.Lock() + if p.workspaceReadOnly { + if status.Revision != p.status.Revision || (p.owns && (p.busy || status.State == "released" || status.State == "expired") && status.State != "preparing" && status.State != "ready") { + r.mu.Unlock() + return + } + } + if r.closed || r.suspension != nil { + r.mu.Unlock() + return + } + r.shutdownWG.Add(1) + r.mu.Unlock() + go func() { + defer r.shutdownWG.Done() + // A failed terminal notification must not restart incomplete cleanup. + if !r.sendPreparation(p.requestID, p.trace, status) && (!p.workspaceReadOnly || status.State == "preparing" || status.State == "ready") { + r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + } + }() +} + +func (r *Router) sendPreparation(requestID, trace string, status proto.PreparationStatusPayload) bool { + return r.sendPreparationUntil(requestID, trace, status, time.Now().Add(5*time.Second)) +} + +func (r *Router) sendPreparationUntil(requestID, trace string, status proto.PreparationStatusPayload, deadline time.Time) bool { + ctx, stop := r.shutdownContext(context.Background()) + defer stop() + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + env, err := proto.NewEnvelopeWithTrace(proto.TypePreparationStatus, requestID, status, trace) + return err == nil && r.sender.Send(ctx, env) == nil +} + +func (r *Router) rejectPreparation(env proto.Envelope, code string) error { + r.mu.Lock() + if !r.closed { + r.shutdownWG.Add(1) + go func() { + defer r.shutdownWG.Done() + r.sendPreparation(env.ID, env.Trace, proto.PreparationStatusPayload{State: "rejected", ErrorCode: code, Operation: env.Type}) + }() + } + r.mu.Unlock() + return errors.New("dispatch: " + code) +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel.go b/apps/daemon/internal/dispatch/preparation_cancel.go similarity index 95% rename from apps/parsar-daemon/internal/dispatch/preparation_cancel.go rename to apps/daemon/internal/dispatch/preparation_cancel.go index 95cb3a23f..c0da3fcaf 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cancel.go +++ b/apps/daemon/internal/dispatch/preparation_cancel.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const preparedCancelTimeout = 10 * time.Second diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go rename to apps/daemon/internal/dispatch/preparation_cancel_test.go index 56818cd98..b21a5c21c 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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" ) type cancellationPreparation struct { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go b/apps/daemon/internal/dispatch/preparation_cleanup.go similarity index 100% rename from apps/parsar-daemon/internal/dispatch/preparation_cleanup.go rename to apps/daemon/internal/dispatch/preparation_cleanup.go diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go b/apps/daemon/internal/dispatch/preparation_cleanup_test.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go rename to apps/daemon/internal/dispatch/preparation_cleanup_test.go index a36355bd5..20a45212b 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type retryablePreparation struct { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go rename to apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index c82ac841e..a21823c6a 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -5,8 +5,8 @@ import ( "errors" "sync" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Old fault-injection fixtures model disposable executors, not reusable native implementations. diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/preparation_start.go rename to apps/daemon/internal/dispatch/preparation_start.go index 99ff3759e..fac37084c 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) error { diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go new file mode 100644 index 000000000..efceb15b2 --- /dev/null +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -0,0 +1,397 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/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" +) + +type controlledPreparation struct { + closed chan struct{} + once sync.Once + mu sync.Mutex + session agent.Session + starts atomic.Int32 + start func(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) + closeHook func() +} + +func (p *controlledPreparation) Close() error { + p.once.Do(func() { + if p.closeHook != nil { + p.closeHook() + } + if p.closed != nil { + close(p.closed) + } + }) + return nil +} +func (p *controlledPreparation) Start(ctx context.Context, id string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + p.starts.Add(1) + session, err := p.start(ctx, id, prompt, out) + if session != nil { + p.mu.Lock() + p.session = session + p.mu.Unlock() + } + return session, err +} + +func (p *controlledPreparation) Cancel(ctx context.Context) error { + p.mu.Lock() + session := p.session + p.mu.Unlock() + if session != nil { + return session.Cancel(ctx) + } + return p.Close() +} + +func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { + p.mu.Lock() + session := p.session + p.mu.Unlock() + if provider, ok := session.(interface{ CancellationOutcome() proto.DonePayload }); ok { + return provider.CancellationOutcome() + } + return proto.DonePayload{} +} + +const preparationEnvironmentID = "11111111-1111-4111-8111-111111111111" +const preparationSessionID = "22222222-2222-4222-8222-222222222222" + +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, + "OAC_RUNTIME_NETWORK_ACCESS": "enabled", + "OAC_RUNTIME_ALLOWED_DOMAINS": "", + } { + t.Setenv(name, value) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + return binding +} + +func localPreparationHarness(t *testing.T) *harness { + t.Helper() + h := newHarness(t) + if err := h.router.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + var err error + h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, LocalWorkspace: preparationWorkspace(t)}) + if err != nil { + t.Fatal(err) + } + return h +} + +func preparationRequest() proto.ExecutionPreparePayload { + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} +} + +func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { + t.Helper() + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("ordinary Factory must not be used for preparation") + }) + reg.RegisterPreparation("prepared", true, factory) + reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return r +} + +func waitPreparationStatus(t *testing.T, sender *recSender, request, state string, differentHandle string) proto.PreparationStatusPayload { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, env := range sender.snapshot() { + var p proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.ID == request && env.DecodePayload(&p) == nil && p.State == state && (differentHandle == "" || p.Handle != differentHandle) { + return p + } + } + time.Sleep(time.Millisecond) + } + t.Fatalf("preparation %s did not reach %s", request, state) + return proto.PreparationStatusPayload{} +} + +func waitPreparationClosed(t *testing.T, p *controlledPreparation) { + t.Helper() + select { + case <-p.closed: + case <-time.After(3 * time.Second): + t.Fatal("native preparation leaked") + } +} + +func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { + sender := &recSender{} + p := &controlledPreparation{closed: make(chan struct{})} + entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) + r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if req.RunID != "" || len(req.Input) != 0 { + t.Error("run input reached preparation") + } + entered <- ctx + <-allowReturn + return p, nil + }) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { + t.Fatal(err) + } + accepted := waitPreparationStatus(t, sender, "request", "preparing", "") + owner := <-entered + if r.ActiveRuns() != 0 { + t.Fatal("preparation created a run") + } + // Receive-loop operations remain available while native initialization blocks. + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "other-run", proto.PromptCancelPayload{})); err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: accepted.Handle})); err != nil { + t.Fatal(err) + } + select { + case <-owner.Done(): + case <-time.After(time.Second): + t.Fatal("release did not cancel owner") + } + close(allowReturn) + waitPreparationClosed(t, p) + if p.starts.Load() != 0 { + t.Fatal("released preparation started work") + } + for _, env := range sender.snapshot() { + if env.Type == proto.TypeError || env.Type == proto.TypeDone { + t.Fatal("preparation emitted run terminal frames") + } + } +} + +func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { + sender := &recSender{} + gotSession := make(chan *fakeSession, 1) + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(ctx context.Context, id string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + if id != "real-run" || *prompt[0].Content[0].Text != "actual input" { + t.Error("start identity or prompt changed") + } + s := &fakeSession{ctx: ctx, out: out, closeOutOnCancel: true} + gotSession <- s + return s, nil + } + r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + ready := waitPreparationStatus(t, sender, "request", "ready", "") + if err := r.Handle(t.Context(), prepare); err != nil { + t.Fatal(err) + } + start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("actual input")}) + if err := r.Handle(t.Context(), start); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, sender, "request", "started", "") + session := <-gotSession + if err := r.Handle(t.Context(), start); err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + // The old preparation deadline must not govern the transferred Session. + time.Sleep(100 * time.Millisecond) + if session.ctx.Err() != nil || session.cancels() != 0 || p.starts.Load() != 1 || r.ActiveRuns() != 1 { + t.Fatal("transfer was duplicated or cancelled") + } + select { + case <-p.closed: + t.Fatal("transferred preparation closed") + default: + } + session.out <- mustEnv(t, proto.TypeDone, "real-run", proto.DonePayload{Content: "complete"}) + deadline := time.Now().Add(time.Second) + for r.ActiveRuns() != 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if r.ActiveRuns() != 0 || session.cancels() != 1 { + t.Fatal("normal completion release was bypassed") + } +} + +func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { + sender := &recSender{} + entered, cancelEntered, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) + lateSession := make(chan *fakeSession, 1) + p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} + p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + close(entered) + <-allowReturn + s := &fakeSession{out: out, closeOutOnCancel: true} + lateSession <- s + return s, nil + } + p.cancel = func(ctx context.Context) error { + close(cancelEntered) + return (<-lateSession).Cancel(ctx) + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + ready := waitPreparationStatus(t, sender, "request", "ready", "") + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) + <-entered + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "real-run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + select { + case <-cancelEntered: + case <-time.After(time.Second): + t.Fatal("fixed cancellation target was not called across Start") + } + close(allowReturn) + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "late cancelled Session cleanup") + p.mu.Lock() + session := p.session.(*fakeSession) + p.mu.Unlock() + if session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatal("late session resurrected cancelled run") + } + select { + case <-p.controlledPreparation.closed: + t.Fatal("transferred preparation was released a second time") + default: + } + for _, frame := range sender.snapshot() { + var status proto.PreparationStatusPayload + if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { + t.Fatal("cancelled start published active Session") + } + } +} + +func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { + sender := &recSender{} + created := make(chan *controlledPreparation, 2) + r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + p := &controlledPreparation{closed: make(chan struct{})} + created <- p + return p, nil + }) + env := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) + _ = r.Handle(t.Context(), env) + old := waitPreparationStatus(t, sender, "request", "ready", "") + waitPreparationStatus(t, sender, "request", "expired", "") + owner := <-created + select { + case <-owner.closed: + t.Fatal("admission expiry closed a healthy executor") + default: + } + _ = r.Handle(t.Context(), env) + next := waitPreparationStatus(t, sender, "request", "ready", old.Handle) + if next.Handle == old.Handle || next.ExpiresAt <= old.ExpiresAt { + t.Fatal("replacement reused expired identity") + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: old.Handle, ExecutorID: old.ExecutorID, RunID: "late", Input: proto.TextInput("late")})); err == nil { + t.Fatal("old handle started replacement") + } +} + +type failReadySender struct{ *recSender } + +func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { + var status proto.PreparationStatusPayload + if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil && status.State == "ready" { + return errors.New("controlled send failure") + } + return s.recSender.Send(ctx, env) +} + +func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { + created := make(chan struct{}) + p := &controlledPreparation{closed: make(chan struct{})} + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + close(created) + return p, nil + }) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + <-created + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "abandoned admission") + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + waitPreparationClosed(t, p) + if r.ActiveRuns() != 0 { + t.Fatal("failed preparation became a run") + } +} + +func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { + for name, change := range map[string]func(*proto.PromptRequestPayload){ + "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, + "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, + "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, + "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, + "attachment": func(p *proto.PromptRequestPayload) { + p.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} + }, + "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, + "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, + } { + t.Run(name, func(t *testing.T) { + r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + t.Error("invalid preparation reached native factory") + return nil, errors.New("invalid") + }) + req := preparationRequest() + change(&req.Configuration) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", req)); err == nil { + t.Fatal("invalid preparation accepted") + } + if r.ActiveRuns() != 0 { + t.Fatal("invalid configuration became a Run") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/daemon/internal/dispatch/prepared_handoff.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/prepared_handoff.go rename to apps/daemon/internal/dispatch/prepared_handoff.go index 89c3d1862..8bdd11834 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/daemon/internal/dispatch/prepared_handoff.go @@ -5,9 +5,9 @@ import ( "errors" "sync" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) var errPreparedStatusDelivery = errors.New("prepared execution status delivery failed") diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go similarity index 99% rename from apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go rename to apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 2e8c9b044..095ec5642 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type preparedMutationSession struct { diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go similarity index 99% rename from apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go rename to apps/daemon/internal/dispatch/prepared_handoff_test.go index f51beec1c..f70e4db7b 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) const preparedBurstFrames = 96 diff --git a/apps/parsar-daemon/internal/dispatch/prompt.go b/apps/daemon/internal/dispatch/prompt.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/prompt.go rename to apps/daemon/internal/dispatch/prompt.go index 56bedf83d..f2b0dbbb3 100644 --- a/apps/parsar-daemon/internal/dispatch/prompt.go +++ b/apps/daemon/internal/dispatch/prompt.go @@ -5,9 +5,9 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelope) error { diff --git a/apps/parsar-daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go similarity index 95% rename from apps/parsar-daemon/internal/dispatch/receipt_order_test.go rename to apps/daemon/internal/dispatch/receipt_order_test.go index b6e8a7aca..8fb22b4c4 100644 --- a/apps/parsar-daemon/internal/dispatch/receipt_order_test.go +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type blockedReceiptSender struct { diff --git a/apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go b/apps/daemon/internal/dispatch/receipt_shutdown_test.go similarity index 89% rename from apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go rename to apps/daemon/internal/dispatch/receipt_shutdown_test.go index 5f66820e4..4455f59ac 100644 --- a/apps/parsar-daemon/internal/dispatch/receipt_shutdown_test.go +++ b/apps/daemon/internal/dispatch/receipt_shutdown_test.go @@ -1,14 +1,14 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "testing" "time" ) diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/router.go rename to apps/daemon/internal/dispatch/router.go index a3a58a4ef..90610b186 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -16,10 +16,10 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "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" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // Sender is the subset of transport.Conn the dispatcher needs. Tests diff --git a/apps/parsar-daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/router_test.go rename to apps/daemon/internal/dispatch/router_test.go index 59290389e..71f4a01d6 100644 --- a/apps/parsar-daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) // --------------------------------------------------------------------- diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/runtime_preparation.go rename to apps/daemon/internal/dispatch/runtime_preparation.go index 9975e9a41..1ed576542 100644 --- a/apps/parsar-daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -7,9 +7,9 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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/google/uuid" ) diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go similarity index 91% rename from apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go rename to apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index eb7d59f7f..1b9954af5 100644 --- a/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Directory selection is syntactically valid and passes frozen configuration diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go rename to apps/daemon/internal/dispatch/runtime_preparation_test.go index bcdbcda11..feb7fbf6e 100644 --- a/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "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" ) diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/shutdown.go rename to apps/daemon/internal/dispatch/shutdown.go index 15d7f7061..c40d4875e 100644 --- a/apps/parsar-daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type shutdownAttempt struct { diff --git a/apps/daemon/internal/dispatch/steering.go b/apps/daemon/internal/dispatch/steering.go new file mode 100644 index 000000000..cd6acfcf2 --- /dev/null +++ b/apps/daemon/internal/dispatch/steering.go @@ -0,0 +1,204 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// Retain all attempts for the active run; reject overflow rather than evicting +// receipts and risking a duplicate native input. Durable recovery is server-owned. +const ( + maxSteeringInputs = 256 + steeringCallTimeout = 10 * time.Second + steeringSendTimeout = 5 * time.Second +) + +type steeringReceipt struct { + fingerprint [32]byte + ack proto.PromptSteerAckPayload + durable bool +} + +func (r *Router) handlePromptSteer(ctx context.Context, env proto.Envelope) error { + var input proto.PromptSteerPayload + ack := proto.PromptSteerAckPayload{} + if err := env.DecodePayload(&input); err != nil { + ack.ErrorCode, ack.Error = "invalid_input", "Invalid steering payload." + } else { + ack.InputID = input.InputID + if env.ID == "" || strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || input.Input.Validate() != nil { + ack.ErrorCode, ack.Error = "invalid_input", "Run ID, input ID (up to 256 bytes), and non-empty text are required." + } else { + pending := r.queueSteering(ctx, env, input) + if pending == nil { + return nil + } + ack = *pending + } + } + return r.sendSteeringAck(ctx, env, ack) +} + +func (r *Router) sendSteeringAck(ctx context.Context, env proto.Envelope, ack proto.PromptSteerAckPayload) error { + reply, err := proto.NewEnvelopeWithTrace(proto.TypePromptSteerAck, env.ID, ack, env.Trace) + if err != nil { + return err + } + return r.sender.Send(ctx, reply) +} + +func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input proto.PromptSteerPayload) *proto.PromptSteerAckPayload { + ack := proto.PromptSteerAckPayload{InputID: input.InputID} + r.mu.Lock() + defer r.mu.Unlock() + state := r.sessions[env.ID] + if state == nil || r.closed { + ack.ErrorCode, ack.Error = "run_inactive", "The run is no longer active." + return &ack + } + encoded, _ := json.Marshal(input.Input) + fingerprint := sha256.Sum256(encoded) + if previous, ok := state.steering[input.InputID]; ok { + if previous.fingerprint != fingerprint || previous.durable != input.DurableReceipt { + ack.ErrorCode, ack.Error = "input_conflict", "This input ID was already used with different text." + return &ack + } + if state.steeringClosed || previous.ack.ErrorCode != "not_ready" && previous.ack.ErrorCode != "busy" { + return &previous.ack + } + } else if len(state.steering) >= maxSteeringInputs { + ack.ErrorCode, ack.Error = "input_limit", "The active run has reached its steering input limit." + return &ack + } + if state.steeringClosed { + ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." + return &ack + } + if state.steering == nil { + state.steering = make(map[string]steeringReceipt) + } + ack.ErrorCode, ack.Error = "not_ready", "The run is still starting." + // Bind input identity before any retryable state so changed text cannot + // slip through a startup or in-flight retry. + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + if state.session == nil { + return &ack + } + // The admitted declaration is immutable even if discovery changes later. + if input.DurableReceipt && !state.capabilities.DurableInputReceipts.IsSupported() || !input.DurableReceipt && !state.capabilities.Steering.IsSupported() { + ack.ErrorCode, ack.Error = "unsupported", "The runtime declaration does not support this input operation." + return &ack + } + session := state.session + steerer, supportsSteering := session.(agent.Steerer) + if input.DurableReceipt { + if _, ok := session.(agent.DurableSteerer); !ok || (!state.releaseOnCompletion && state.preparedHandoff == nil) { + ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." + return &ack + } + } else if !supportsSteering { + ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." + return &ack + } + if state.steerBusy { + ack.ErrorCode, ack.Error = "busy", "Another input is awaiting an engine receipt; retry this input later." + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + return &ack + } + session, finishOperation, ready := r.preparedOperationLocked(state) + if !ready { + ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." + return &ack + } + ack.ErrorCode, ack.Error = "in_flight", "This input is awaiting an engine receipt." + state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} + state.steerBusy = true + finished := make(chan struct{}) + state.steeringDone = finished + r.shutdownWG.Add(1) + go func() { + defer r.shutdownWG.Done() + defer finishOperation() + defer func() { + r.mu.Lock() + state.steerBusy = false + close(finished) + r.mu.Unlock() + }() + ctx, stop := r.shutdownContext(ctx) + defer stop() + var err error + if input.DurableReceipt { + err = r.steerDurably(ctx, state, session, env, input, fingerprint) + } else { + callCtx, cancel := context.WithTimeout(ctx, steeringCallTimeout) + err = steerer.Steer(callCtx, input) + cancel() + } + r.publishSteeringReceipt(ctx, state, env, input, fingerprint, steeringResult(input.InputID, err)) + }() + return nil +} + +func steeringResult(inputID string, err error) proto.PromptSteerAckPayload { + ack := proto.PromptSteerAckPayload{InputID: inputID} + switch { + case errors.Is(err, agent.ErrUnsupportedOperation): + ack.ErrorCode, ack.Error = "contract_violation", "Declared input capability has no implementation." + case errors.Is(err, agent.ErrSteeringNotReady): + ack.ErrorCode, ack.Error = "not_ready", err.Error() + case errors.Is(err, agent.ErrSteeringInactive): + ack.ErrorCode, ack.Error = "run_inactive", err.Error() + case errors.Is(err, agent.ErrSteeringRejected): + ack.ErrorCode, ack.Error = "rejected", err.Error() + case err != nil: + // A timeout or broken connection may follow native acceptance. Preserve + // the uncertainty and never automatically send this input again. + ack.ErrorCode, ack.Error = "outcome_unknown", err.Error() + default: + ack.Accepted = true + } + return ack +} + +// shutdownContext releases cooperating work when the router shuts down. +func (r *Router) shutdownContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + go func() { + select { + case <-r.shutdownCh: + cancel() + case <-ctx.Done(): + } + }() + return ctx, cancel +} + +// Close admission before taking the last worker: its lifetime includes the +// receipt send, so a durable Done cannot close the gateway subscription first. +func (r *Router) finishSteering(state *sessionState) error { + r.mu.Lock() + state.steeringClosed = true + finished := state.steeringDone + r.mu.Unlock() + if finished == nil { + return nil + } + timer := time.NewTimer(steeringCallTimeout + steeringSendTimeout) + defer timer.Stop() + select { + case <-finished: + return nil + case <-r.shutdownCh: + return context.Canceled + case <-timer.C: + return context.DeadlineExceeded + } +} diff --git a/apps/parsar-daemon/internal/dispatch/steering_lifetime.go b/apps/daemon/internal/dispatch/steering_lifetime.go similarity index 91% rename from apps/parsar-daemon/internal/dispatch/steering_lifetime.go rename to apps/daemon/internal/dispatch/steering_lifetime.go index 17b8f944c..b696de6a4 100644 --- a/apps/parsar-daemon/internal/dispatch/steering_lifetime.go +++ b/apps/daemon/internal/dispatch/steering_lifetime.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (r *Router) steerDurably(sendCtx context.Context, state *sessionState, session agent.Session, env proto.Envelope, input proto.PromptSteerPayload, fingerprint [32]byte) error { diff --git a/apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go b/apps/daemon/internal/dispatch/steering_lifetime_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go rename to apps/daemon/internal/dispatch/steering_lifetime_test.go index b8c20c7ed..c51b87b87 100644 --- a/apps/parsar-daemon/internal/dispatch/steering_lifetime_test.go +++ b/apps/daemon/internal/dispatch/steering_lifetime_test.go @@ -1,6 +1,6 @@ package dispatch_test -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) type durableSteeringSession struct { diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go new file mode 100644 index 000000000..4f4158a70 --- /dev/null +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -0,0 +1,277 @@ +package dispatch_test + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type steeringSession struct { + *fakeSession + steer func(context.Context, proto.PromptSteerPayload) error +} + +func (s *steeringSession) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.steer(ctx, input) +} + +func TestSteeringReceiptsAndRetries(t *testing.T) { + for _, engineError := range []error{nil, errors.New("native connection lost after write")} { + name := "accepted" + if engineError != nil { + name = "uncertain" + } + t.Run(name, func(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls, starts := 0, 0 + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + starts++ + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(ctx context.Context, input proto.PromptSteerPayload) error { + calls++ + if _, ok := ctx.Deadline(); !ok { + t.Error("native request has no deadline") + } + if input.InputID != "input-1" || *input.Input[0].Content[0].Text != "additional text" { + t.Errorf("input lost: %+v", input) + } + if len(h.sender.snapshot()) != 0 { + t.Error("ack sent before engine accepted input") + } + return engineError + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("additional text")} + env := mustEnv(t, proto.TypePromptSteer, "run-1", input) + // An ack transport failure must not cause another native invocation. + h.sender.failNow = true + if err := h.router.Handle(ctx, env); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { h.sender.mu.Lock(); defer h.sender.mu.Unlock(); return !h.sender.failNow }, "failed ack send") + for range 2 { + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + ack := lastSteeringAck(t, h.sender, "run-1", "input-1") + if ack.Accepted != (engineError == nil) { + t.Fatalf("receipt: %+v", ack) + } + if engineError != nil && ack.ErrorCode != "outcome_unknown" { + t.Fatalf("uncertainty lost: %+v", ack) + } + } + input.Input = proto.TextInput("changed text") + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { + t.Fatalf("conflict: %+v", ack) + } + if calls != 1 || starts != 1 { + t.Fatalf("native calls=%d, runs started=%d", calls, starts) + } + }) + } +} + +func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls := 0 + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + calls++ + if calls == 1 { + return agent.ErrSteeringNotReady + } + return nil + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("extra")} + env := mustEnv(t, proto.TypePromptSteer, "run-1", input) + for _, expected := range []string{"not_ready", ""} { + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != expected { + t.Fatalf("expected %q: %+v", expected, ack) + } + if expected == "not_ready" { + changed := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("different during startup")} + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", changed)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { + t.Fatalf("startup identity changed: %+v", ack) + } + } + } + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-1", nil)); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "cancel cleanup") + if err := handleSteeringAndWait(t, h, env); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { + t.Fatalf("inactive: %+v", ack) + } + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + t.Fatal(err) + } + session := <-h.gotSess + defer close(session.out) + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "unsupported" { + t.Fatalf("unsupported: %+v", ack) + } + input.Input = proto.TextInput("") + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "invalid_input" { + t.Fatalf("invalid: %+v", ack) + } + // Whitespace-only text is content: dispatch forwards it like any other text. + input.InputID, input.Input = "input-2", proto.TextInput(" \n ") + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-2", "input-2"); ack.ErrorCode != "unsupported" { + t.Fatalf("whitespace: %+v", ack) + } +} + +func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + entered, release := make(chan struct{}), make(chan struct{}) + defer close(release) + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + close(entered) + <-release + return agent.ErrSteeringRejected + }, + }, nil + }) + ctx := context.Background() + for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "claude_code"}} { + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { + t.Fatal(err) + } + } + other := <-h.gotSess + other.closeOutOnCancel = true + returned := make(chan error, 1) + go func() { + returned <- h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "slow", Input: proto.TextInput("extra")})) + }() + select { + case err := <-returned: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("steering blocked dispatch") + } + <-entered + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { + t.Fatal(err) + } + if other.cancels() != 1 { + t.Fatal("other run cancellation blocked") + } +} + +func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { + t.Helper() + frames := sender.snapshot() + if len(frames) == 0 { + t.Fatal("missing ack") + } + env := frames[len(frames)-1] + var ack proto.PromptSteerAckPayload + if env.Type != proto.TypePromptSteerAck || env.ID != runID { + t.Fatalf("incorrect routing: %+v", env) + } + if err := env.DecodePayload(&ack); err != nil { + t.Fatal(err) + } + if ack.InputID != inputID { + t.Fatalf("incorrect input: %+v", ack) + } + return ack +} + +func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + calls := 0 + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + calls++ + return nil + }, + }, nil + }) + ctx := context.Background() + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { + t.Fatal(err) + } + for i := range 257 { + input := proto.PromptSteerPayload{InputID: fmt.Sprintf("input-%d", i), Input: proto.TextInput("extra")} + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { + t.Fatal(err) + } + ack := lastSteeringAck(t, h.sender, "run-1", input.InputID) + if i < 256 && !ack.Accepted || i == 256 && ack.ErrorCode != "input_limit" { + t.Fatalf("input %d: %+v", i, ack) + } + } + if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "input-0", Input: proto.TextInput("extra")})); err != nil { + t.Fatal(err) + } + if ack := lastSteeringAck(t, h.sender, "run-1", "input-0"); !ack.Accepted || calls != 256 { + t.Fatalf("receipt evicted or input redelivered: %+v, calls=%d", ack, calls) + } +} + +func handleSteeringAndWait(t *testing.T, h *harness, env proto.Envelope) error { + t.Helper() + before := len(h.sender.snapshot()) + if err := h.router.Handle(context.Background(), env); err != nil { + return err + } + waitFor(t, func() bool { return len(h.sender.snapshot()) > before }, "steering ack") + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/suspend.go b/apps/daemon/internal/dispatch/suspend.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/suspend.go rename to apps/daemon/internal/dispatch/suspend.go index e52df4858..003c9ce2f 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend.go +++ b/apps/daemon/internal/dispatch/suspend.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) var ErrRouterQuiesced = errors.New("dispatch: router quiesced") diff --git a/apps/parsar-daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/suspend_test.go rename to apps/daemon/internal/dispatch/suspend_test.go index e03347a74..6fe34b285 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type suspendSender func(context.Context, proto.Envelope) error diff --git a/apps/parsar-daemon/internal/dispatch/work.go b/apps/daemon/internal/dispatch/work.go similarity index 100% rename from apps/parsar-daemon/internal/dispatch/work.go rename to apps/daemon/internal/dispatch/work.go diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go new file mode 100644 index 000000000..89de922ed --- /dev/null +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -0,0 +1,64 @@ +package dispatch_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { + sender := &recSender{} + p := &controlledPreparation{closed: make(chan struct{})} + p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil + } + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + for _, name := range []string{"file", "second"} { + if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) + ready := waitPreparationStatus(t, sender, "prepare", "ready", "") + request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 1} + for _, phase := range []string{"idle", "active"} { + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) + result := waitWorkspaceRead(t, sender, phase) + if result.Outcome != "completed" || !result.CloseAcknowledged || result.Directory == nil || !result.Directory.Truncated || len(result.Directory.Entries) != 1 || len(result.Data) != 0 { + t.Fatal(result) + } + bad := request + bad.EnvironmentID = "another-environment" + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase+"-foreign", bad)) + if got := waitWorkspaceRead(t, sender, phase+"-foreign"); got.ErrorCode != "resource_unavailable" { + t.Fatal(got) + } + if phase == "idle" { + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("start")})) + waitPreparationStatus(t, sender, "prepare", "started", "") + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "stale", request)) + if got := waitWorkspaceRead(t, sender, "stale"); got.Outcome != "rejected" { + t.Fatal(got) + } + request.Handle, request.RunID = "", "run" + } + } + for index, bad := range []proto.WorkspaceReadPayload{ + {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2, MaxBytes: 1}, + {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: proto.WorkspaceDirectoryMaxEntries + 1}, + {Operation: "directory", Handle: ready.Handle, RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, + {Operation: "recursive", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, + } { + id := fmt.Sprintf("invalid-%d", index) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, id, bad)) + if got := waitWorkspaceRead(t, sender, id); got.Outcome != "rejected" || got.ErrorCode != "invalid_request" || got.Directory != nil { + t.Fatal("malformed directory control reached a resource", got) + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go similarity index 98% rename from apps/parsar-daemon/internal/dispatch/workspace_export.go rename to apps/daemon/internal/dispatch/workspace_export.go index 112be4db4..86fd2cb43 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -6,7 +6,7 @@ import ( "io" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type workspaceExport struct { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/workspace_export_test.go rename to apps/daemon/internal/dispatch/workspace_export_test.go index 0a3959c9b..90dba1e5e 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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" ) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go b/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go similarity index 94% rename from apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go rename to apps/daemon/internal/dispatch/workspace_preparation_failure_test.go index a239ed0eb..fa48438e5 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_preparation_failure_test.go +++ b/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type workspaceFailureBoundary struct { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go rename to apps/daemon/internal/dispatch/workspace_preparation_status_test.go index 8989ecb44..ab8b4b98e 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go +++ b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) type workspaceStatusSender chan proto.Envelope diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go new file mode 100644 index 000000000..4fd1111a5 --- /dev/null +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -0,0 +1,157 @@ +package dispatch + +import ( + "context" + "errors" + "io/fs" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +const workspaceReadCapacity = 4 + +func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) error { + // Never echo an unbounded correlation ID onto the shared connection. + if len(env.ID) > proto.WorkspaceReadMaxIDBytes { + return errors.New("dispatch: invalid workspace read ID") + } + var request proto.WorkspaceReadPayload + if len(env.Payload) > proto.WorkspaceReadMaxRequestBytes || env.DecodePayload(&request) != nil || strings.TrimSpace(env.ID) == "" || + !proto.ValidWorkspaceReadRequest(request) { + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("invalid_request")) + } + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return ErrRouterClosed + } + if _, exists := r.workspaceReads[env.ID]; exists { + r.mu.Unlock() + return errors.New("dispatch: workspace read already pending") + } + if len(r.workspaceReads) >= workspaceReadCapacity { + r.mu.Unlock() + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("read_capacity")) + } + resource, code := r.workspaceResourceLocked(request) + if code != "" { + r.mu.Unlock() + return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) + } + if r.workspaceReads == nil { + r.workspaceReads = make(map[string]struct{}) + } + r.workspaceReads[env.ID] = struct{}{} + r.shutdownWG.Add(1) + r.mu.Unlock() + go func() { + defer r.shutdownWG.Done() + defer func() { r.mu.Lock(); delete(r.workspaceReads, env.ID); r.mu.Unlock() }() + // 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) + _ = r.sendWorkspaceRead(context.WithoutCancel(ctx), env, result) + }() + return nil +} + +func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (any, string) { + var resource any + 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" + } + resource = p.prepared + 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 + } + if r.localWorkspace != nil { + resource = r.localWorkspace + } + return resource, "" +} + +func executeWorkspaceRead(ctx context.Context, resource any, 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) + 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) + } + 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") + } + read, err := reader.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) + return workspaceReadResult(read, err, request.MaxBytes) +} + +func rejectedWorkspaceRead(code string) proto.WorkspaceReadResultPayload { + return proto.WorkspaceReadResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func workspaceReadResult(read agent.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} + } + for _, failure := range []struct { + err error + code string + }{ + {agent.ErrWorkspaceReadUnsupported, "read_unsupported"}, + {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, + {agent.ErrWorkspaceReadBusy, "read_capacity"}, + {agent.ErrWorkspaceReadInvalid, "invalid_request"}, + {agent.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, + {fs.ErrNotExist, "not_found"}, + {fs.ErrPermission, "permission_denied"}, + } { + if errors.Is(err, failure.err) { + return rejectedWorkspaceRead(failure.code) + } + } + return proto.WorkspaceReadResultPayload{Outcome: "unknown", ErrorCode: "read_unconfirmed"} +} + +func (r *Router) sendWorkspaceRead(ctx context.Context, request proto.Envelope, result proto.WorkspaceReadResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + trace := request.Trace + if len(trace) > 256 { + trace = "" + } + env, err := proto.NewEnvelopeWithTrace(proto.TypeWorkspaceReadResult, request.ID, result, trace) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go similarity index 97% rename from apps/parsar-daemon/internal/dispatch/workspace_write.go rename to apps/daemon/internal/dispatch/workspace_write.go index 7226ef20d..cd4722a6a 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -7,8 +7,8 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/daemon/internal/dispatch/workspace_write_test.go similarity index 95% rename from apps/parsar-daemon/internal/dispatch/workspace_write_test.go rename to apps/daemon/internal/dispatch/workspace_write_test.go index b2530eaf8..e6e710fe9 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/daemon/internal/dispatch/workspace_write_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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/google/uuid" ) diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go similarity index 92% rename from apps/parsar-daemon/internal/localworkspace/binding.go rename to apps/daemon/internal/localworkspace/binding.go index 8bceb4a83..94abe0744 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -7,11 +7,11 @@ import ( "strings" "sync" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go similarity index 96% rename from apps/parsar-daemon/internal/localworkspace/binding_test.go rename to apps/daemon/internal/localworkspace/binding_test.go index c954e6e0c..ff243918c 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go new file mode 100644 index 000000000..1dae83a6d --- /dev/null +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -0,0 +1,204 @@ +package localworkspace + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" +) + +// Prepare runs under the admitted executor's lifetime, before native startup. +// Reconnection validates installed contents without reopening mutable sources. +func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + if b == nil && r.LocalEnvironment == nil || r.WorkspaceReadOnly { + return r, nil + } + if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.CapabilitySources == nil || + r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.WorkDir != b.workspace { + return r, agentcapabilities.ErrInvalid + } + b.capabilityMu.Lock() + defer b.capabilityMu.Unlock() + marker, err := b.openSnapshotMarker() + if err != nil { + return r, err + } + defer marker.close() + root, unlock, err := b.openCapabilities(ctx, marker.completed) + if err != nil { + return r, err + } + defer unlock() + input := *r.LocalEnvironment.CapabilitySources + identity := b.capabilityIdentity() + if err := prepareToolEnvironment(r.LocalEnvironment.ToolEnvironment, marker.completed); err != nil { + return r, err + } + manifest, err := b.loadCapabilitySnapshot(root, input, identity, marker.completed) + if err != nil || marker.complete() != nil { + return r, agentcapabilities.ErrInvalid + } + if err = ctx.Err(); err != nil { + return r, err + } + local := *r.LocalEnvironment + local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot + for i := range local.Skills { + local.Skills[i].InstallationRoot = b.capabilityRoot + } + if local.ToolEnvironment { + if _, err = ReadToolEnvironment(); err != nil { + return r, err + } + } + if len(manifest.MCP) != 0 { + if b.NetworkPolicy().Access != "enabled" { + return r, agentcapabilities.ErrInvalid + } + values, readErr := b.ReadToolEnvironment() + if readErr != nil { + return r, readErr + } + 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 + } + } + r.LocalEnvironment = &local + return r, nil +} + +// ApplyRuntimePreparation settles every filesystem operation before returning. A +// cancelled caller never leaves an unowned installation goroutine behind. +func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.RuntimePreparePayload, data []byte) error { + if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { + return agentcapabilities.ErrInvalid + } + b.capabilityMu.Lock() + defer b.capabilityMu.Unlock() + marker, err := b.openSnapshotMarker() + if err != nil { + return err + } + defer marker.close() + if marker.completed { + return agentcapabilities.ErrInvalid + } + if err := ctx.Err(); err != nil { + return err + } + switch input.Action { + case "file": + if len(data) != input.SizeBytes { + return agentcapabilities.ErrInvalid + } + return b.installInitialFile(ctx, *input.File, data) + case "initialize": + if len(data) != 0 { + return agentcapabilities.ErrInvalid + } + return b.initializeRuntime(ctx, *input.Initialization) + } + root, unlock, err := b.openCapabilities(ctx, false) + if err != nil { + return err + } + defer unlock() + switch input.Action { + case "skill": + err = agentcapabilities.InstallSkill(root, data, *input.Skill) + case "plugin": + err = agentcapabilities.InstallPlugin(root, input.Slot, data, *input.Plugin) + case "finalize": + if err := prepareToolEnvironment(false, false); err != nil { + return err + } + _, err = b.loadCapabilitySnapshot(root, *input.Sources, b.capabilityIdentity(), false) + if err == nil { + err = marker.complete() + } + default: + err = agentcapabilities.ErrInvalid + } + if err != nil { + return agentcapabilities.ErrInvalid + } + return ctx.Err() +} + +func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.Input, 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 + } + } else if err != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + manifest, err := agentcapabilities.Load(root) + if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + return manifest, nil +} + +func (b *Binding) capabilityIdentity() agentcapabilities.Identity { + return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: strings.TrimPrefix(b.stateKey, "agents-api-")} +} + +// The current Linux Runtime layout is shared by user-owned and managed hosts. +// Deployment paths never come from a capability transport request. +func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Root, func(), error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if b.capabilityRoot == "" || runtimefs.ValidateLocalPath(b.capabilityRoot) != nil { + return nil, nil, agentcapabilities.ErrInvalid + } + if !completed { + if err := os.MkdirAll(b.capabilityRoot, 0700); err != nil { + return nil, nil, agentcapabilities.ErrInvalid + } + } + root, err := os.OpenRoot(b.capabilityRoot) + if err != nil { + return nil, nil, agentcapabilities.ErrInvalid + } + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + root.Close() + return nil, nil, agentcapabilities.ErrInvalid + } + return root, func() { unlock(); root.Close() }, nil +} + +func containsPath(parent, child string) bool { + relative, err := filepath.Rel(parent, child) + return err == nil && (relative == "." || filepath.IsLocal(relative)) +} + +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")) + } + // Refuse recursive installation into the selected source itself. + if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { + return nil, agentcapabilities.ErrInvalid + } + root, err := os.OpenRoot(source) + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + return root, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go b/apps/daemon/internal/localworkspace/capabilities_test.go similarity index 91% rename from apps/parsar-daemon/internal/localworkspace/capabilities_test.go rename to apps/daemon/internal/localworkspace/capabilities_test.go index 2713ae93e..224213fca 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go +++ b/apps/daemon/internal/localworkspace/capabilities_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go similarity index 98% rename from apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go rename to apps/daemon/internal/localworkspace/capability_preparation_test.go index 365b77f02..0d226d55e 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/daemon/internal/localworkspace/capability_preparation_test.go @@ -7,8 +7,8 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go similarity index 79% rename from apps/parsar-daemon/internal/localworkspace/directory.go rename to apps/daemon/internal/localworkspace/directory.go index 3679a60c4..3104ed8ef 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory.go +++ b/apps/daemon/internal/localworkspace/directory.go @@ -5,8 +5,8 @@ import ( "io/fs" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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) { diff --git a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go b/apps/daemon/internal/localworkspace/directory_native_test.go similarity index 96% rename from apps/parsar-daemon/internal/localworkspace/directory_native_test.go rename to apps/daemon/internal/localworkspace/directory_native_test.go index 142c69062..ef22420f6 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go +++ b/apps/daemon/internal/localworkspace/directory_native_test.go @@ -2,7 +2,7 @@ package localworkspace import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" diff --git a/apps/parsar-daemon/internal/localworkspace/export.go b/apps/daemon/internal/localworkspace/export.go similarity index 91% rename from apps/parsar-daemon/internal/localworkspace/export.go rename to apps/daemon/internal/localworkspace/export.go index 015e020ab..a853e7aaf 100644 --- a/apps/parsar-daemon/internal/localworkspace/export.go +++ b/apps/daemon/internal/localworkspace/export.go @@ -3,7 +3,7 @@ package localworkspace import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "io" ) diff --git a/apps/parsar-daemon/internal/localworkspace/export_test.go b/apps/daemon/internal/localworkspace/export_test.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/export_test.go rename to apps/daemon/internal/localworkspace/export_test.go diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/daemon/internal/localworkspace/initialization.go similarity index 97% rename from apps/parsar-daemon/internal/localworkspace/initialization.go rename to apps/daemon/internal/localworkspace/initialization.go index e4240761a..4d99c1d40 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/daemon/internal/localworkspace/initialization.go @@ -8,8 +8,8 @@ import ( "runtime" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // InitializationDirectory resolves the operator's local resource layout. The diff --git a/apps/daemon/internal/localworkspace/mcp.go b/apps/daemon/internal/localworkspace/mcp.go new file mode 100644 index 000000000..820c852e4 --- /dev/null +++ b/apps/daemon/internal/localworkspace/mcp.go @@ -0,0 +1,47 @@ +package localworkspace + +import ( + "errors" + "os" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// MCPStdioCommand resolves the common installed manifest in the daemon. +func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { + executable, err := os.Executable() + if err != nil { + return "", nil + } + return executable, []string{"runtime-mcp-exec", server.InstallationRoot, server.PackageRoot, server.Server.Name} +} + +func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { + result := make([]proto.EnvironmentMCP, 0, len(installed)) + names := map[string]bool{} + for _, item := range installed { + server := item.Server + if names[server.Name] { + return nil, errors.New("ambiguous environment MCP server identity") + } + names[server.Name] = true + resolved := proto.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: server} + variables := append([]string{}, server.EnvVars...) + if server.BearerTokenEnvVar != "" { + variables = append(variables, server.BearerTokenEnvVar) + } + for _, name := range variables { + value, exists := values[name] + if !exists || strings.ContainsRune(value, 0) { + return nil, errors.New("declared environment MCP variable unavailable") + } + if name == server.BearerTokenEnvVar { + resolved.BearerToken = &value + } + } + result = append(result, resolved) + } + return result, nil +} diff --git a/apps/daemon/internal/localworkspace/mcp_test.go b/apps/daemon/internal/localworkspace/mcp_test.go new file mode 100644 index 000000000..d0e52e3cc --- /dev/null +++ b/apps/daemon/internal/localworkspace/mcp_test.go @@ -0,0 +1,43 @@ +package localworkspace + +import ( + "os" + "slices" + "testing" + + "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{ + Name: "remote", Type: "http", URL: "https://example.com/mcp", BearerTokenEnvVar: "PLUGIN_TOKEN", + }}} + if _, err := resolveEnvironmentMCP(installed, nil); err == nil { + t.Fatal("native credential became a user Plugin credential") + } + servers, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}) + if err != nil || len(servers) != 1 || servers[0].BearerToken == nil || *servers[0].BearerToken != "user-token" { + t.Fatal("initialized credential was not selected", err) + } + installed = append(installed, agentcapabilities.InstalledMCP{PackageRoot: "plugins/1", Server: installed[0].Server}) + if _, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}); err == nil { + t.Fatal("ambiguous server identity accepted") + } +} + +func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { + server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, + }} + command, args := MCPStdioCommand(server) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if command != executable || !slices.Equal(args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "package_tool"}) { + t.Fatal("native configuration included untrusted process configuration") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go similarity index 95% rename from apps/parsar-daemon/internal/localworkspace/native_binding.go rename to apps/daemon/internal/localworkspace/native_binding.go index bf39d35b5..4805dba7b 100644 --- a/apps/parsar-daemon/internal/localworkspace/native_binding.go +++ b/apps/daemon/internal/localworkspace/native_binding.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go similarity index 98% rename from apps/parsar-daemon/internal/localworkspace/native_files.go rename to apps/daemon/internal/localworkspace/native_files.go index 07961f13f..e669bc66a 100644 --- a/apps/parsar-daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -11,8 +11,8 @@ import ( "sort" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/localworkspace/native_open_unix.go b/apps/daemon/internal/localworkspace/native_open_unix.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/native_open_unix.go rename to apps/daemon/internal/localworkspace/native_open_unix.go diff --git a/apps/parsar-daemon/internal/localworkspace/native_open_unix_test.go b/apps/daemon/internal/localworkspace/native_open_unix_test.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/native_open_unix_test.go rename to apps/daemon/internal/localworkspace/native_open_unix_test.go diff --git a/apps/parsar-daemon/internal/localworkspace/native_open_windows.go b/apps/daemon/internal/localworkspace/native_open_windows.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/native_open_windows.go rename to apps/daemon/internal/localworkspace/native_open_windows.go diff --git a/apps/parsar-daemon/internal/localworkspace/network.go b/apps/daemon/internal/localworkspace/network.go similarity index 94% rename from apps/parsar-daemon/internal/localworkspace/network.go rename to apps/daemon/internal/localworkspace/network.go index 372d57115..220561590 100644 --- a/apps/parsar-daemon/internal/localworkspace/network.go +++ b/apps/daemon/internal/localworkspace/network.go @@ -5,7 +5,7 @@ import ( "errors" "os" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) // RuntimeNetworkPolicy reads deployment configuration, never caller options. diff --git a/apps/parsar-daemon/internal/localworkspace/network_policy_test.go b/apps/daemon/internal/localworkspace/network_policy_test.go similarity index 95% rename from apps/parsar-daemon/internal/localworkspace/network_policy_test.go rename to apps/daemon/internal/localworkspace/network_policy_test.go index b1ebd7800..96a7a9d10 100644 --- a/apps/parsar-daemon/internal/localworkspace/network_policy_test.go +++ b/apps/daemon/internal/localworkspace/network_policy_test.go @@ -1,8 +1,8 @@ package localworkspace import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" "testing" ) diff --git a/apps/parsar-daemon/internal/localworkspace/package_command.go b/apps/daemon/internal/localworkspace/package_command.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/package_command.go rename to apps/daemon/internal/localworkspace/package_command.go diff --git a/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go b/apps/daemon/internal/localworkspace/package_command_windows_test.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go rename to apps/daemon/internal/localworkspace/package_command_windows_test.go diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go new file mode 100644 index 000000000..1c8164fef --- /dev/null +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -0,0 +1,225 @@ +package localworkspace + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" +) + +// InitializationFailure contains only a confirmed step's safe exit status. +type InitializationFailure struct{ ExitCode *int } + +func (*InitializationFailure) Error() string { return "Runtime initialization failed" } + +var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") + +func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { + raw, err := json.Marshal(input) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + if input.Action == "configure" { + return configureRuntime(input.Env) + } + if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { + return agentcapabilities.ErrInvalid + } + if input.Network != "enabled" && input.Network != "disabled" { + return agentcapabilities.ErrInvalid + } + directory, err := b.initializationCWD(input.CWD) + if err != nil { + return err + } + values, err := ReadToolEnvironment() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + var binary string + var args []string + switch input.Action { + case "setup": + if input.Command == "" || strings.ContainsRune(input.Command, 0) { + return agentcapabilities.ErrInvalid + } + binary, err = initializationBash() + args = []string{"--noprofile", "--norc", "-c", input.Command} + case "npm", "python": + if len(input.Packages) == 0 { + return agentcapabilities.ErrInvalid + } + for _, value := range input.Packages { + if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { + return agentcapabilities.ErrInvalid + } + } + if err = os.MkdirAll(packages, 0700); err != nil { + return &InitializationFailure{} + } + if input.Action == "npm" { + binary, args, err = initializationNPM() + args = append(args, "install", "--global", "--prefix", filepath.Join(packages, "npm"), "--") + } else { + binary, err = initializationPython() + args = []string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", filepath.Join(packages, "python"), "--"} + } + args = append(args, input.Packages...) + } + if err != nil { + return err + } + return runInitializationProcess(ctx, binary, args, directory, initializationEnvironment(values)) +} + +func (b *Binding) initializationCWD(value string) (string, error) { + if value == "" || value == "/workspace" { + return b.workspace, nil + } + if !strings.HasPrefix(value, "/workspace/") { + return "", agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) + if err != nil { + return "", agentcapabilities.ErrInvalid + } + return filepath.Join(b.workspace, relative), nil +} + +func configureRuntime(values map[string]string) error { + if !validToolEnvironment(values) { + return agentcapabilities.ErrInvalid + } + path, err := initializedToolEnvironmentPath() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { + return &InitializationFailure{} + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + return &InitializationFailure{} + } + defer unlock() + if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { + return &InitializationFailure{} + } + configured := make(map[string]string, len(values)+2) + if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { + if runtimefs.ValidateLocalPath(source) != nil { + return &InitializationFailure{} + } + local, err := readToolEnvironmentFile(source) + if err != nil { + return &InitializationFailure{} + } + for key, value := range local { + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + configured[key] = value + } + } + // Explicit Session values override the operator's base tool configuration. + for key, value := range values { + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + configured[key] = value + } + separator := string(os.PathListSeparator) + npmBin := filepath.Join(packages, "npm", "bin") + pythonBin := filepath.Join(packages, "python", "bin") + if runtime.GOOS == "windows" { + npmBin = filepath.Join(packages, "npm") + pythonBin = filepath.Join(packages, "python", "Scripts") + } + pathValue, exists := configured["PATH"] + if !exists { + pathValue = os.Getenv("PATH") + } + configured["PATH"] = npmBin + separator + pythonBin + separator + pathValue + pythonPath := configured["PYTHONPATH"] + configured["PYTHONPATH"] = filepath.Join(packages, "python") + if pythonPath != "" { + configured["PYTHONPATH"] += separator + pythonPath + } + raw, err := json.Marshal(configured) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +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 { + return agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) + if err != nil { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + w := b.writer + if !w.mu.TryLock() { + return agentcapabilities.ErrInvalid + } + defer w.mu.Unlock() + if w.uncertain { + return ErrInitializationUnconfirmed + } + defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() + root, err := os.OpenRoot(b.workspace) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + if root.MkdirAll(filepath.Dir(relative), 0700) != nil { + return &InitializationFailure{} + } + parent, err := root.OpenRoot(filepath.Dir(relative)) + if err != nil { + return &InitializationFailure{} + } + defer parent.Close() + // Initial files replace existing contents, unlike public Files create. Finish + // the synchronous atomic write before returning even if cancellation arrives. + if runtimefs.WritePrivateAtomic(parent, filepath.Base(relative), data) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +func initializationDependency(name string) error { + return fmt.Errorf("Runtime initialization requires %s: %w", name, &InitializationFailure{}) +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go similarity index 96% rename from apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go rename to apps/daemon/internal/localworkspace/runtime_initialization_process.go index b054648e2..e98dc070b 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) func initializationBash() (string, error) { diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go new file mode 100644 index 000000000..19ed6e18d --- /dev/null +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -0,0 +1,363 @@ +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// The test executable doubles as a native package-manager fixture on all OSes. +// Dispatch before testing parses npm/pip arguments; no dependencies are fetched. +func init() { + if os.Getenv("OAC_INITIALIZATION_PACKAGE_FIXTURE") != "1" { + return + } + raw, _ := json.Marshal(os.Args[1:]) + if os.WriteFile(os.Getenv("OAC_INITIALIZATION_PACKAGE_RECEIPT"), raw, 0600) != nil { + os.Exit(9) + } + os.Exit(0) +} + +func TestRuntimeInitializationPackageTargets(t *testing.T) { + b := initializationFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + for _, name := range []string{"node", "npm", "python3"} { + if name == "npm" && runtime.GOOS == "windows" { + if err = os.WriteFile(filepath.Join(bin, "npm.cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { + t.Fatal(err) + } + continue + } + if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { + t.Fatal(err) + } + } + if runtime.GOOS == "windows" { + cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") + if err = os.MkdirAll(filepath.Dir(cli), 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(cli, nil, 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + receipt := filepath.Join(t.TempDir(), "args.json") + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OAC_INITIALIZATION_PACKAGE_FIXTURE": "1", "OAC_INITIALIZATION_PACKAGE_RECEIPT": receipt}}); err != nil { + t.Fatal(err) + } + packages, _ := PackageDirectory() + for _, action := range []string{"npm", "python"} { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + t.Fatal(action, err) + } + raw, err := os.ReadFile(receipt) + if err != nil { + t.Fatal(err) + } + var args []string + if json.Unmarshal(raw, &args) != nil { + t.Fatal("invalid fixture receipt") + } + if action == "npm" && runtime.GOOS == "windows" { + cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") + if len(args) == 0 || args[0] != cli { + t.Fatal("initializer bypassed the shared npm shim resolver", args) + } + } + flag, target := "--prefix", filepath.Join(packages, "npm") + if action == "python" { + flag, target = "--target", filepath.Join(packages, "python") + } + found := false + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == target { + found = true + } + } + if !found || len(args) < 3 || args[len(args)-3] != "--" || args[len(args)-2] != "name with spaces" || args[len(args)-1] != "second" { + t.Fatal("package argv or local target mismatch", args) + } + } +} + +func TestRuntimeInitializationChild(t *testing.T) { + mode := os.Getenv("OAC_INITIALIZATION_FIXTURE") + if mode == "" { + return + } + directory := os.Getenv("OAC_INITIALIZATION_FIXTURE_DIR") + switch mode { + case "complete": + if os.Getenv("RUNTIME_TEST_PRIVATE") != "" { + os.Exit(9) + } + os.Stdout.Write(bytes.Repeat([]byte("private-output"), 10000)) + os.Stderr.Write(bytes.Repeat([]byte("private-error"), 10000)) + case "fail": + os.Exit(7) + case "child": + time.Sleep(time.Second) + _ = os.WriteFile(filepath.Join(directory, "late"), []byte("bad"), 0600) + case "parent": + binary, _ := os.Executable() + child := exec.Command(binary, "-test.run=^TestRuntimeInitializationChild$") + child.Env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=child", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(8) + } + _ = os.WriteFile(filepath.Join(directory, "started"), []byte("ready"), 0600) + _ = child.Wait() + } + os.Exit(0) +} + +func initializationFixture(t *testing.T) *Binding { + t.Helper() + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} + +func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { + b := initializationFixture(t) + config, err := InitializationDirectory() + if err != nil || config != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "initialization") { + t.Fatal(config, err) + } + packages, err := PackageDirectory() + if err != nil || packages != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "packages") { + t.Fatal(packages, err) + } + env := map[string]string{"VALUE": "'\n$(not-a-command)", "PYTHONPATH": "operator-path"} + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: env}); err != nil { + t.Fatal(err) + } + values, err := ReadToolEnvironment() + if err != nil || values["VALUE"] != env["VALUE"] || !strings.HasPrefix(values["PYTHONPATH"], filepath.Join(packages, "python")) { + t.Fatal("configuration mismatch", err) + } + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "changed"}}) == nil { + t.Fatal("configuration replaced") + } + values, _ = ReadToolEnvironment() + if values["VALUE"] != env["VALUE"] { + t.Fatal("configuration changed") + } + for setting, resolver := range map[string]func() (string, error){"OAC_RUNTIME_INITIALIZATION_DIRECTORY": InitializationDirectory, "OAC_RUNTIME_PACKAGE_DIRECTORY": PackageDirectory} { + selected := t.TempDir() + t.Setenv(setting, selected) + if actual, err := resolver(); err != nil || actual != selected { + t.Fatal("operator directory ignored", err) + } + } +} + +func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { + b := initializationFixture(t) + file := filepath.Join(t.TempDir(), "explicit.json") + original := []byte(`{"DECLARED":"value","OVERRIDE":"local"}`) + if err := os.WriteFile(file, original, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + env, err := ReadOptionalToolEnvironment() + if err != nil || env["DECLARED"] != "value" { + t.Fatal("explicit tool environment", err) + } + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OVERRIDE": "session"}}); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(file); err != nil || string(raw) != string(original) { + t.Fatal("operator source was modified", err) + } + // Reconnect reads the frozen result even after the operator edits its source. + if err := os.WriteFile(file, []byte(`{"DECLARED":"changed"}`), 0600); err != nil { + t.Fatal(err) + } + env, err = ReadOptionalToolEnvironment() + if err != nil || env["DECLARED"] != "value" || env["OVERRIDE"] != "session" { + t.Fatal("tool snapshot was not frozen", err) + } + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { + t.Fatal("configuration replay succeeded") + } +} + +func TestRuntimeInitializationRejectsMissingExplicitToolEnvironment(t *testing.T) { + b := initializationFixture(t) + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { + t.Fatal("missing explicit environment was ignored") + } + path, err := initializedToolEnvironmentPath() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("failed configuration left a prepared snapshot") + } +} + +func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { + b := initializationFixture(t) + if _, err := initializationBash(); err != nil { + t.Skip("Bash unavailable; native dependency failure is tested separately") + } + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "configured"}}); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "sub"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { + t.Fatal(err) + } + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"proof": "skill-proof", "value": "configured"} { + raw, err := os.ReadFile(filepath.Join(b.workspace, "sub", name)) + if err != nil || string(raw) != want { + t.Fatal(name, err) + } + } +} + +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) + } + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + var failed *InitializationFailure + if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { + t.Fatal("missing dependency was not explicit", input.Action, err) + } + } + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { + t.Fatal("invalid request accepted", input.Action) + } + } +} + +func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { + t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=complete") + if err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env); err != nil { + t.Fatal(err) + } + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") + err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + var failed *InitializationFailure + if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { + t.Fatal("exit status", err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + done := make(chan error, 1) + go func() { + done <- runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err = os.Stat(filepath.Join(directory, "started")); err == nil { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("fixture startup timeout") + } + time.Sleep(5 * time.Millisecond) + } + cancel() + if err = <-done; !errors.Is(err, ErrInitializationUnconfirmed) { + t.Fatal("cancel should be unknown", err) + } + time.Sleep(1100 * time.Millisecond) + if _, err = os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { + t.Fatal("descendant survived initialization") + } +} + +func TestRuntimeInitialFileAtomicReplacement(t *testing.T) { + b := initializationFixture(t) + path := "/workspace/nested/child/file" + for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(b.workspace, "nested", "child", "file")) + if err != nil || !bytes.Equal(actual, body) { + t.Fatal("atomic replacement", err) + } + } + for _, invalid := range []string{"/elsewhere/file", "/workspace/../outside", "/workspace/a\\b"} { + if !errors.Is(b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: invalid}, []byte("x")), agentcapabilities.ErrInvalid) { + t.Fatal("invalid path accepted") + } + } +} +func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(nil) + input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted file", err) + } + input.Action = "initialize" + input.File = nil + input.SHA256 = "" + input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted initialize", err) + } +} diff --git a/apps/daemon/internal/localworkspace/skills.go b/apps/daemon/internal/localworkspace/skills.go new file mode 100644 index 000000000..f6bbf4d98 --- /dev/null +++ b/apps/daemon/internal/localworkspace/skills.go @@ -0,0 +1,13 @@ +package localworkspace + +import ( + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" +) + +const CapabilityDirectory = agentcapabilities.Directory + +func SkillPath(skill agentcapabilities.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 new file mode 100644 index 000000000..be37c0358 --- /dev/null +++ b/apps/daemon/internal/localworkspace/skills_test.go @@ -0,0 +1,15 @@ +package localworkspace + +import ( + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "path/filepath" + "testing" +) + +func TestSkillPathUsesRuntimeInstallationRoot(t *testing.T) { + root := t.TempDir() + skill := agentcapabilities.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/parsar-daemon/internal/localworkspace/snapshot_marker.go b/apps/daemon/internal/localworkspace/snapshot_marker.go similarity index 94% rename from apps/parsar-daemon/internal/localworkspace/snapshot_marker.go rename to apps/daemon/internal/localworkspace/snapshot_marker.go index a55443dd6..fb95e9de1 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker.go @@ -6,9 +6,9 @@ import ( "io" "os" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "github.com/google/uuid" ) diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/daemon/internal/localworkspace/snapshot_marker_test.go similarity index 97% rename from apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go rename to apps/daemon/internal/localworkspace/snapshot_marker_test.go index 9c9cf40b4..b57484af7 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker_test.go @@ -8,9 +8,9 @@ import ( "runtime" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "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) { diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go similarity index 91% rename from apps/parsar-daemon/internal/localworkspace/write.go rename to apps/daemon/internal/localworkspace/write.go index 53ce99910..05e7a6dc2 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -6,8 +6,8 @@ import ( "io/fs" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "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. diff --git a/apps/parsar-daemon/internal/localworkspace/write_binding.go b/apps/daemon/internal/localworkspace/write_binding.go similarity index 100% rename from apps/parsar-daemon/internal/localworkspace/write_binding.go rename to apps/daemon/internal/localworkspace/write_binding.go diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/daemon/internal/localworkspace/write_test.go similarity index 97% rename from apps/parsar-daemon/internal/localworkspace/write_test.go rename to apps/daemon/internal/localworkspace/write_test.go index 5d953a421..fe15e99f9 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/daemon/internal/localworkspace/write_test.go @@ -5,7 +5,7 @@ import ( "context" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" diff --git a/apps/parsar-daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go similarity index 98% rename from apps/parsar-daemon/internal/paths/paths.go rename to apps/daemon/internal/paths/paths.go index 6b48dc6bb..fb4bcec94 100644 --- a/apps/parsar-daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -8,7 +8,7 @@ package paths import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "os" "path/filepath" "regexp" diff --git a/apps/parsar-daemon/internal/paths/paths_test.go b/apps/daemon/internal/paths/paths_test.go similarity index 98% rename from apps/parsar-daemon/internal/paths/paths_test.go rename to apps/daemon/internal/paths/paths_test.go index 4f42c4057..fc151033e 100644 --- a/apps/parsar-daemon/internal/paths/paths_test.go +++ b/apps/daemon/internal/paths/paths_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) // withTempHome points OAC_RUNTIME_HOME at a fresh tempdir for the test. diff --git a/apps/parsar-daemon/internal/transport/bootstrap.go b/apps/daemon/internal/transport/bootstrap.go similarity index 100% rename from apps/parsar-daemon/internal/transport/bootstrap.go rename to apps/daemon/internal/transport/bootstrap.go diff --git a/apps/daemon/internal/transport/bootstrap_test.go b/apps/daemon/internal/transport/bootstrap_test.go new file mode 100644 index 000000000..1b8467f4f --- /dev/null +++ b/apps/daemon/internal/transport/bootstrap_test.go @@ -0,0 +1,161 @@ +package transport_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" +) + +func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { + var sawAuth, sawCT, sawDeviceID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agent-daemon/bootstrap" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method %q", r.Method) + } + sawAuth = r.Header.Get("Authorization") + sawCT = r.Header.Get("Content-Type") + var body struct { + DeviceID string `json:"device_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + sawDeviceID = body.DeviceID + 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() + + resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt_abc", "secret123", "0.0.0-dev") + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + if resp.DeviceID != "rt_abc" || resp.WorkspaceID != "ws_xyz" { + t.Errorf("unexpected response: %+v", resp) + } + if resp.WSURL != "wss://example/agent-daemon/ws" { + t.Errorf("ws_url = %q", resp.WSURL) + } + if got := resp.HeartbeatInterval().Seconds(); got != 15 { + t.Errorf("HeartbeatInterval = %vs, want 15s", got) + } + if sawAuth != "Bearer secret123" { + t.Errorf("Authorization = %q, want Bearer secret123", sawAuth) + } + if sawCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", sawCT) + } + if sawDeviceID != "rt_abc" { + t.Errorf("body.device_id = %q, want rt_abc", sawDeviceID) + } +} + +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 + }) + })) + defer srv.Close() + + resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + if got := resp.HeartbeatInterval().Seconds(); got != 15 { + t.Errorf("HeartbeatInterval = %vs, want 15s default", got) + } +} + +func TestBootstrapNonSuccessSurfacesBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad_credential","detail":"wrong key"}`)) + })) + defer srv.Close() + + _, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") + if err == nil { + t.Fatal("Bootstrap returned nil error on 401") + } + if !strings.Contains(err.Error(), "bad_credential") || !strings.Contains(err.Error(), "401") { + t.Errorf("error %q missing 'bad_credential' or '401'", err.Error()) + } +} + +func TestBootstrapRejectsEmptyInputs(t *testing.T) { + cases := []struct{ name, base, device, cred string }{ + {"empty base", "", "rt", "c"}, + {"empty device", "https://x", "", "c"}, + {"empty cred", "https://x", "rt", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := transport.Bootstrap(context.Background(), tc.base, tc.device, tc.cred, "v") + if err == nil { + t.Fatal("Bootstrap accepted empty input") + } + }) + } +} + +func TestDeriveWSURLPrefersAbsolute(t *testing.T) { + got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") + if err != nil { + t.Fatalf("DeriveWSURL: %v", err) + } + if got != "wss://prod/agent-daemon/ws" { + t.Errorf("got %q, want wss://prod/agent-daemon/ws", got) + } +} + +func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { + cases := []struct{ base, want string }{ + {"https://core.example.com", "wss://core.example.com/agent-daemon/ws"}, + {"http://localhost:3000", "ws://localhost:3000/agent-daemon/ws"}, + {"http://localhost:3000/", "ws://localhost:3000/agent-daemon/ws"}, + {"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) + if err != nil { + t.Errorf("DeriveWSURL(%q): %v", tc.base, err) + continue + } + if got != tc.want { + t.Errorf("DeriveWSURL(%q) = %q, want %q", tc.base, got, tc.want) + } + } +} + +func TestDeriveWSURLRejectsRelativeWSURL(t *testing.T) { + _, err := transport.DeriveWSURL(transport.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") + if err == nil { + t.Fatal("DeriveWSURL accepted ftp scheme") + } +} diff --git a/apps/parsar-daemon/internal/transport/reconnect.go b/apps/daemon/internal/transport/reconnect.go similarity index 100% rename from apps/parsar-daemon/internal/transport/reconnect.go rename to apps/daemon/internal/transport/reconnect.go diff --git a/apps/parsar-daemon/internal/transport/reconnect_test.go b/apps/daemon/internal/transport/reconnect_test.go similarity index 98% rename from apps/parsar-daemon/internal/transport/reconnect_test.go rename to apps/daemon/internal/transport/reconnect_test.go index a1320e024..5de2c113f 100644 --- a/apps/parsar-daemon/internal/transport/reconnect_test.go +++ b/apps/daemon/internal/transport/reconnect_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" ) func TestBackoffPolicyDelayGrowsExponentiallyAndCaps(t *testing.T) { diff --git a/apps/parsar-daemon/internal/transport/ws.go b/apps/daemon/internal/transport/ws.go similarity index 98% rename from apps/parsar-daemon/internal/transport/ws.go rename to apps/daemon/internal/transport/ws.go index 93f7f95f4..feeb2a7a5 100644 --- a/apps/parsar-daemon/internal/transport/ws.go +++ b/apps/daemon/internal/transport/ws.go @@ -12,11 +12,11 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // DialOptions is the input to Dial. HTTPHeader is optional. diff --git a/apps/parsar-daemon/internal/transport/ws_test.go b/apps/daemon/internal/transport/ws_test.go similarity index 98% rename from apps/parsar-daemon/internal/transport/ws_test.go rename to apps/daemon/internal/transport/ws_test.go index 83d628de7..13d5e2390 100644 --- a/apps/parsar-daemon/internal/transport/ws_test.go +++ b/apps/daemon/internal/transport/ws_test.go @@ -14,8 +14,8 @@ import ( "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // fakeGateway is a minimal stand-in for the server-side diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go new file mode 100644 index 000000000..8ecc4dbaf --- /dev/null +++ b/apps/daemon/testdata/onboarding/main.go @@ -0,0 +1,198 @@ +// This synthetic harness exercises the production router without a native model. +// It is test-only, has no workspace/tools, and is never in the built-in catalog. +package main + +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" +) + +type sender struct { + mu sync.Mutex + encoder *json.Encoder +} + +func (s *sender) Send(_ context.Context, e proto.Envelope) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.encoder.Encode(e) +} + +type harness struct { + mu sync.Mutex + history map[string]string +} + +func (h *harness) prepare(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, errors.New("preparation submitted fixture input") + } + if !req.StrictResume || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + return nil, errors.New("unsupported fixture operation") + } + h.mu.Lock() + defer h.mu.Unlock() + previous := h.history[req.AgentStateKey] + if req.AgentSessionID != previous || (req.RequireExistingNativeSession && previous == "") { + return nil, errors.New("native history mismatch") + } + if previous == "" { + previous = "fixture-" + req.AgentStateKey + h.history[req.AgentStateKey] = previous + } + return &executor{native: previous}, nil +} + +type executor struct { + mu sync.Mutex + native string + active *session + closed bool +} + +func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx.Err() != nil || run == "" || out == nil { + return nil, errors.New("invalid fixture Start") + } + if _, err := input.TextOnly(); err != nil { + return nil, err + } + e.mu.Lock() + defer e.mu.Unlock() + if e.closed || e.active != nil { + return nil, errors.New("fixture Executor unavailable") + } + s := &session{out: out, run: run, native: e.native, settled: make(chan struct{})} + s.release = func() { + e.mu.Lock() + if e.active == s { + e.active = nil + } + e.mu.Unlock() + } + e.active = s + s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: "ready", Sequence: 1}) + return s, nil +} + +func (e *executor) Close(ctx context.Context) error { + e.mu.Lock() + e.closed = true + current := e.active + e.mu.Unlock() + if current != nil { + return current.Cancel(ctx) + } + return nil +} + +type session struct { + mu sync.Mutex + out chan<- proto.Envelope + run, native string + closed bool + settled chan struct{} + release func() +} + +func (s *session) emit(kind string, payload any) { + e, _ := proto.NewEnvelope(kind, s.run, payload) + s.out <- e +} +func (s *session) Cancel(context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.closed { + s.closed = true + close(s.out) + s.release() + close(s.settled) + } + return nil +} +func (s *session) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "cancelled", Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} +} +func (s *session) Steer(ctx context.Context, p proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, p, func() {}) +} +func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload, written func()) error { + text, err := p.Input.TextOnly() + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return agent.ErrSteeringInactive + } + written() + s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: 2}) + s.emit(proto.TypeDone, proto.DonePayload{Content: "ready" + text, Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) + s.closed = true + close(s.out) + s.release() + close(s.settled) + return nil +} + +func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-s.settled: + return agent.TurnSettlement{Reusable: true}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func run() error { + registry := agent.NewRegistry() + h := &harness{history: map[string]string{}} + registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ + Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, + })}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("fixture execution requires an Executor") + }) + registry.RegisterExecutor("fixture_harness", h.prepare) + sink := &sender{encoder: json.NewEncoder(os.Stdout)} + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) + if err != nil { + return err + } + defer router.Shutdown(context.Background()) + heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: registry.SupportedAgentKinds()}) + if err = sink.Send(context.Background(), heartbeat); err != nil { + return err + } + decoder := json.NewDecoder(os.Stdin) + for { + var e proto.Envelope + if err = decoder.Decode(&e); errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + if err = router.Handle(context.Background(), e); err != nil { + return err + } + } +} +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index b64a0de5b..5d33fa897 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -59,7 +59,7 @@ failures retain the sign-in error shape above. Poll the same-origin GET while preparing. Applying the change restarts Web and ends its sign-in sessions; provide a link to the submitted HTTPS origin for a fresh login. A dropped request or cross-origin browser probe does not prove -success. The [installer contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https) owns +success. The [installer contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https) owns certificate verification, locking, retry and rollback. ## Console to Core @@ -74,9 +74,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -87,8 +87,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model configuration | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-configuration`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model configuration | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-configuration`; the key is write-only ([contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -101,9 +101,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), +The [deployment configuration contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [nodes guide](/hosted-providers) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml) +and [generated OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -144,7 +144,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -185,7 +185,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -194,7 +194,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 6739c038e..ef385bdac 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,21 +22,21 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Inline PNG/JPEG is qualified on `none`, Core-managed Docker `openai_hosted` and `self_hosted`; MiniMax images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts), plus [self-hosted execution and cold continuation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | -| Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. Self-hosted Claude image results use the same native path; see the current [qualification record](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md). MiniMax functions remain unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading for D1. [Self-hosted workspace callback, continuation and cancellation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md) use the same native path; they do not add model-request observer evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace with Skills/Plugins, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](/environments-and-files#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Inline PNG/JPEG is qualified on `none`, Core-managed Docker `openai_hosted` and `self_hosted`; MiniMax images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts), plus [self-hosted execution and cold continuation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. Self-hosted Claude image results use the same native path; see the current [qualification record](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md). MiniMax functions remain unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading for D1. [Self-hosted workspace callback, continuation and cancellation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md) use the same native path; they do not add model-request observer evidence. [Contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace with Skills/Plugins, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](/environments-and-files#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -53,7 +53,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -62,8 +62,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -79,16 +79,16 @@ not a claim that every historical workflow was rerun at the current baseline. | ID | Exact evidence and accepted scope | | --- | --- | -| M1 | `20260922/message-image-input/public-codex-reviewed.log` (59.41s), `public-claude-reviewed.log` (79.52s), `public-mcode-network-fixed.log` (48.95s); [PR #12](https://github.com/MiniMax-AI/parsar-core/pull/12), merge `37a2923`. Codex/Claude Kimi K3 PNG initial/active inputs, receipts, retries, cancellation, cold continuation and isolation; MiniMax M2.7 ordinary text regression. | -| M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | -| F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | -| F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | -| S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | -| S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | -| D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | -| P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| M1 | `20260922/message-image-input/public-codex-reviewed.log` (59.41s), `public-claude-reviewed.log` (79.52s), `public-mcode-network-fixed.log` (48.95s); [PR #12](https://github.com/MiniMax-AI/OpenAgentCore/pull/12), merge `37a2923`. Codex/Claude Kimi K3 PNG initial/active inputs, receipts, retries, cancellation, cold continuation and isolation; MiniMax M2.7 ordinary text regression. | +| M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/OpenAgentCore/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | +| F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/OpenAgentCore/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | +| F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/OpenAgentCore/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/OpenAgentCore/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | +| S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/OpenAgentCore/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | +| D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/OpenAgentCore/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | +| P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/OpenAgentCore/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -122,4 +122,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index a75a05ace..529695e3d 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -3,7 +3,7 @@ title: "Sign in and issue application keys" description: "Keep the Core key on the management side and issue Project API keys through Web." --- -The console server (`services/core-console`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. +The console server (`services/web`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. ## Request boundary @@ -46,7 +46,7 @@ Every request except `/healthz`, `/v1` and `/api/v1` must pass these checks firs 2. **Safe request.** The path must start with `/` and contain no `%`, backslash, NUL, dot segment or empty segment. Absolute-form request targets, `CONNECT`, `TRACE` and any request with an `Upgrade` header get 400. A request can therefore never leave `/core/v1` on Core, and the console carries no WebSocket. 3. **Sign-in.** Paths that need sign-in answer 401 without a valid session cookie. -Under `/core`, these failures use the Core error envelope with the codes in [console-generated failures](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md#console-generated-failures); elsewhere they return `{"error": "…"}`, or plain text for an unsafe request. Every response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: frame-ancestors 'none'`. +Under `/core`, these failures use the Core error envelope with the codes in [console-generated failures](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md#console-generated-failures); elsewhere they return `{"error": "…"}`, or plain text for an unsafe request. Every response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: frame-ancestors 'none'`. ## Forwarding to Core @@ -56,13 +56,13 @@ On the way to Core, the console: - removes the browser's `Authorization`, `Proxy-Authorization`, `Cookie`, `Origin` and `Referer` headers; - sends `Authorization: Bearer `; -- sets `X-Core-Console-Actor: console`, replacing any value the browser sent. Core records it as a display-only audit label ([administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)); +- sets `X-Core-Console-Actor: console`, replacing any value the browser sent. Core records it as a display-only audit label ([administrator API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)); - ignores ambient HTTP proxy settings, so the Core key reaches only the configured Core; - streams responses without buffering. On the way back, it removes `Set-Cookie`, `WWW-Authenticate`, `Location`, `Refresh` and every `Access-Control-*` header. A redirect from Core, or a failed connection to Core, becomes 502 `core_unreachable`. -The console never retries a request. Browser code calls `/core/v1` through the typed clients in [`packages/agents-client`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/README.md); [console API usage](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and writes. +The console never retries a request. Browser code calls `/core/v1` through the typed clients in [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/README.md); [console API usage](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and writes. ## Sign-in @@ -109,7 +109,7 @@ The status has `supported`, `state` (`unconfigured`, `checking`, `applying`, `re Without `OAC_WEB_INSTALLATION_SOCKET` (external reverse proxy installations), `GET` reports `supported: false` and `POST` returns 400 `domain_setup_unavailable`. An unreachable installer or an invalid answer returns 502 `installation_unreachable`. -The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https)). +The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https)). `OAC_WEB_BOOTSTRAP=1`, which the installer sets while no public URL is configured, lets the console also accept plain HTTP requests addressed to a literal IP address, treating `http://` as the origin, so an operator can sign in through the server's IP address. Host names still require `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach the console. @@ -142,4 +142,4 @@ After installing or changing the console, check: A sign-in failure belongs to the console. A 401 from Core on a signed-in request means the console's Core key does not match Core's digest, or the console reaches the wrong Core. The [troubleshooting table](/troubleshooting#troubleshooting) covers the common symptoms. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-server.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-server.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 8b71558f5..ef0c0da2e 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -4,7 +4,7 @@ description: "Projects, credentials and the boundary between applications, admin --- OpenAgentCore implements the OpenAI Agents API under the -[public API rules](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/AGENTS.md#public-api). +[public API rules](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/AGENTS.md#public-api). ## Three namespaces, three credentials @@ -84,12 +84,12 @@ not protect Runtime data from tools running as the same user. Do not add product users, RBAC, cross-Project shared assets, administrator execution or compatibility with old private protocols. Implementers follow the -[design rules](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/AGENTS.md#design-principles) and the +[design rules](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/AGENTS.md#design-principles) and the [Core–Runtime protocol](/runtime-protocol). Native failure classification is adapter-owned and uses finite, structured native values. Optional Runtime error metadata is normalized once; it never replaces Core's terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index f2e05452c..25eac36a0 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -84,7 +84,7 @@ When nodes, hosted sandboxes or self-hosted executors are bound to the current a | `ingress` | `"managed"` \| `"external"` | `"external"` | all | fixed | none | managed provides automatic HTTPS and Web domain setup for a combined Docker installation; install.sh selects it by default. external uses your existing proxy. Fixed after installation. | -The schema is [`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. How Core collects and keeps Runtime history is in [retained history](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability.md#retained-history-and-optional-export). +The schema is [`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. How Core collects and keeps Runtime history is in [retained history](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability.md#retained-history-and-optional-export). ## Runtime settings: Web @@ -93,13 +93,13 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | | Sandbox backend: Docker, microsandbox or E2B | **System** → **Manage sandbox configuration**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation. `install.sh --sandbox` saves the first choice. Another backend needs **Reset deployment** first; see [change the sandbox configuration](/hosted-providers#change-the-sandbox-configuration) | -| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the sizes in [`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/features/sandbox/standard-sizes.json). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | +| Sandbox size, Runtime release, E2B key and template build | **System** → **Manage sandbox configuration** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the sizes in [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/features/sandbox/standard-sizes.json). Existing sandboxes keep their size and release. The E2B key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**; **Edit node** and **Remove node** on a node's page | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](/hosted-providers) | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **System** → **Default model configuration**: **Set** | `/core/v1/harnesses/{harness}/model-configuration` | See [Default models](#default-models) | | Executor credentials of a self-hosted Session | **Session log**, then the **Session** page: **Executor credentials** | `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | See [self-hosted executors](/self-hosted-execution) | -Which harnesses are enabled, and the default one, are process settings (`core.harnesses`, `core.default_harness`); System shows them read-only. The [API index](/public-api#core-api) lists every Core API route, and the [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) defines the sandbox fields, limits and change rules. +Which harnesses are enabled, and the default one, are process settings (`core.harnesses`, `core.default_harness`); System shows them read-only. The [API index](/public-api#core-api) lists every Core API route, and the [deployment contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) defines the sandbox fields, limits and change rules. ### Node capacity @@ -109,7 +109,7 @@ Core approves a node's capacity when you generate its Add node command: **Sandbo ### Default models -Set a default in **System** → **Default model configuration**, or use `PUT /core/v1/harnesses/{harness}/model-configuration`. Core encrypts provider keys with `secrets/credential.key` and never returns them. [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults) owns the request fields and replacement rules, and [precedence](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence) says which Sessions use a default. +Set a default in **System** → **Default model configuration**, or use `PUT /core/v1/harnesses/{harness}/model-configuration`. Core encrypts provider keys with `secrets/credential.key` and never returns them. [Model execution](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults) owns the request fields and replacement rules, and [precedence](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence) says which Sessions use a default. ## Installation directory @@ -137,7 +137,7 @@ In Docker, the Compose project is named `oac-<10 hex digits>` (`project` in `sta ## Appendix: Core environment without the installer -Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see [Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. +Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see [Maintainers and advanced deployments](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. | Variable | Set from | | --- | --- | @@ -160,4 +160,4 @@ Core logs the file paths it loads, never environment values or file contents. A Web you run without the installer reads the variables in [Console server settings](/bootstrap-projects-keys#settings), plus `OAC_WEB_NODE_PAYLOAD_DIR`: the absolute path of the matched distribution's node payload (the installer's `node-payload/`). Without it, Add node is unavailable. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 3e4f607cf..716126125 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Platform | Nodes | Add, edit and remove Docker or microsandbox nodes; each node's readiness, capacity and allocations | | Platform | System | The installation's public address, API base URL, ID and source commit; **Domain and HTTPS**; each harness's default model; **Sandbox configuration**; Core's `config.json` startup settings, read-only, with where to change them | -Missing data is shown as missing (—), never as zero. [Console API usage](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and how its figures are bounded. +Missing data is shown as missing (—), never as zero. [Console API usage](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and how its figures are bounded. ## What administrators do here @@ -45,15 +45,15 @@ Installation creates no Project or key. Opening the console neither allocates co The deployment's sandbox backend serves hosted Sessions. An application's `self_hosted` Runtime, including one in its own E2B account, is a separate path that the sandbox configuration does not change. -A loopback public address (`local_only`) keeps nodes and remote applications from reaching Core. The console stays reachable at its own address and [warns about it](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md#provenance-and-monitoring). +A loopback public address (`local_only`) keeps nodes and remote applications from reaching Core. The console stays reachable at its own address and [warns about it](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md#provenance-and-monitoring). ## More - [Console server](/bootstrap-projects-keys): request boundary, sign-in, settings and verification. -- [Console API usage](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md): the Core routes each page uses. -- [Web package](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md): developing the console. -- [Administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md): the `/core/v1` routes behind the console. +- [Console API usage](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md): the Core routes each page uses. +- [Web package](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md): developing the console. +- [Administrator API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md): the `/core/v1` routes behind the console. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/README.md) diff --git a/apps/docs/content/docs/development.mdx b/apps/docs/content/docs/development.mdx index 23bceb506..aa4832646 100644 --- a/apps/docs/content/docs/development.mdx +++ b/apps/docs/content/docs/development.mdx @@ -6,7 +6,7 @@ description: "Set up a checkout and choose the right extension boundary." This guide is for contributors changing Core, Runtime, adapters, the Web console or the documentation site. For using a deployment, start with the [getting started guide](/). Read the -[contributor rules](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md) before changing code. +[contributor rules](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md) before changing code. ![OpenAgentCore architecture: applications and the Web console connect to Core through separate APIs; Sandbox Providers, Runtime, Harnesses and model providers connect through shared protocols.](/images/source/docs/assets/development-architecture.png) @@ -20,8 +20,8 @@ git worktree add ../openagentcore-change -b codex/my-change main cd ../openagentcore-change ``` -Install Go at the version in [go.mod](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/go.mod), Node 22, pnpm at the version -in [package.json](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/package.json), and Python 3.9 or newer. The complete gate +Install Go at the version in [go.mod](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/go.mod), Node 22, pnpm at the version +in [package.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/package.json), and Python 3.9 or newer. The complete gate runs on Linux and needs a dedicated PostgreSQL database, OpenSSL development libraries for the microsandbox helper, and a Playwright browser. Provider and Runtime builds have additional prerequisites in their component guides. @@ -29,7 +29,7 @@ Runtime builds have additional prerequisites in their component guides. ```sh pnpm install --frozen-lockfile python3 -m venv .venv -.venv/bin/python -m pip install -r services/agents-api/tests/requirements.txt +.venv/bin/python -m pip install -r services/core/tests/requirements.txt .venv/bin/python - <<'PYTHON' import json import subprocess @@ -47,7 +47,7 @@ export OAC_TEST_OFFICIAL_SDK_PYTHON="$PWD/.venv/bin/python" Set `OAC_TEST_DATABASE_URL` privately to a dedicated PostgreSQL test database. Never point the test suite at an installation or product database. The -[test database rules](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#test-database) list the required role +[test database rules](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#test-database) list the required role permission. Third-party native packages are pinned build inputs; changing a pin requires the @@ -58,7 +58,7 @@ relevant native qualification as well as compilation. From the repository root: ```sh -make build-agents-api +make build-core make build-daemon ``` @@ -67,37 +67,37 @@ under `~/.oac/build/daemon/` by default. `OAC_DEV_HOME` selects another build ro `OAC_DEV_CORE_BUILD_DIR` selects an absolute Core output directory. Building does not configure a database, start a deployment or qualify native execution. -Use the [service guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#build-standalone-binaries) +Use the [service guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/README.md#build-standalone-binaries) to run the Core migrator and server with a separate development database. The [configuration appendix](/configure#appendix-core-environment-without-the-installer) owns standalone process settings. For a complete operator installation, use the [installation guide](/install); building Core alone is a separate contributor workflow. -The [Web package guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md) describes console development and +The [Web package guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md) describes console development and its management-only integration. `pnpm dev:web` starts the frontend development server; it does not install Core, issue keys or start native execution. The -[docs app guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) describes the independent documentation +[docs app guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) describes the independent documentation site; `pnpm dev:docs` starts its development server. ## Repository map | Location | Responsibility | Read next | | --- | --- | --- | -| `services/agents-api/internal/api` | Public, administrator and machine HTTP boundaries | [API index](/public-api) | -| `services/agents-api/internal/store` and `internal/db` | Core persistence, transactions, queries and migrations | [Service guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#database-ownership) | -| `services/agents-api/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](/runtime-protocol) | -| `services/agents-api/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](/harness-onboarding) | +| `services/core/internal/api` | Public, administrator and machine HTTP boundaries | [API index](/public-api) | +| `services/core/internal/store` and `internal/db` | Core persistence, transactions, queries and migrations | [Service guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/README.md#database-ownership) | +| `services/core/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](/runtime-protocol) | +| `services/core/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](/harness-onboarding) | | `internal/agentdaemon/proto` and `gateway` | Shared wire types, validators and authenticated Runtime connections | [Runtime protocol](/runtime-protocol) | | `internal/runtimebootstrap` | Provider-to-Runtime startup input | [Runtime bootstrap](/runtime-bootstrap) | -| `apps/parsar-daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](/harness-onboarding#required-adapter-interfaces) | -| `apps/parsar-daemon/internal/agent` | Native harness adapters | [Native references](/harness-onboarding#native-references) | -| `services/agents-api/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](/sandbox-provider) | -| `services/core-console` | Console login and the server-side management proxy | [Console server](/bootstrap-projects-keys) | -| `apps/web` and `packages/agents-client` | Console UI and typed clients | [Web guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md) | -| `deploy/install` and `scripts` | Distribution, installation and validation tools | [Maintainers](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/maintainers.md) | -| `contracts/agents-api` | Pinned schema, local semantic contracts and qualification evidence | [Coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) | -| `apps/docs` | Generated guide and API-reference website | [Generation workflow](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) | +| `apps/daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](/harness-onboarding#required-adapter-interfaces) | +| `apps/daemon/internal/agent` | Native harness adapters | [Native references](/harness-onboarding#native-references) | +| `services/core/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](/sandbox-provider) | +| `services/web` | Console login and the server-side management proxy | [Console server](/bootstrap-projects-keys) | +| `apps/web` and `packages/agents-client` | Console UI and typed clients | [Web guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/README.md) | +| `deploy/install` and `scripts` | Distribution, installation and validation tools | [Maintainers](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/maintainers.md) | +| `contracts/agents-api` | Pinned schema, local semantic contracts and qualification evidence | [Coverage ledger](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) | +| `apps/docs` | Generated guide and API-reference website | [Generation workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) | Core owns durable execution facts. Runtime owns local execution and cleanup. Adapters translate native operations. Providers own outer compute. These boundaries @@ -115,21 +115,21 @@ only helps you pick the right one. | Sandbox Provider | Outer compute that creates and reclaims Environments | [Sandbox Provider guide](/sandbox-provider) | | Runtime bootstrap | Starting a managed Runtime with its connection identity | [Runtime bootstrap](/runtime-bootstrap) | | Core–Runtime protocol | A message, receipt or lifecycle rule between Core and the daemon | [Core–Runtime protocol](/runtime-protocol) | -| Public API operation | A `/v1`, `/core/v1` or `/api/v1` route | [API index](/public-api) and [contracts](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) | +| Public API operation | A `/v1`, `/core/v1` or `/api/v1` route | [API index](/public-api) and [contracts](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) | | Environment capability | Skills, Plugins, MCP or `packages.system` preparation | [Environments](/environments-and-files#runtime-capability-preparation) | ### Add a Harness adapter Implement the shared `ExecutorFactory`, `Executor` and `Turn` interfaces in -[`agent/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/harness.go), register +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/harness.go), register the adapter and add its profile/configuration entry to the shared catalog. Follow the numbered steps in [Harness onboarding](/harness-onboarding); qualification -evidence belongs in [Harness integration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md). +evidence belongs in [Harness integration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md). ### Add a Sandbox Provider Implement the five required `SandboxProvider` operations in -[`sandbox_provider.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/sandbox_provider.go), +[`sandbox_provider.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/sandbox_provider.go), register the provider kind and pass `make check-sandbox-provider-contract`. Follow the numbered steps in the [Sandbox Provider guide](/sandbox-provider). @@ -143,12 +143,12 @@ ownership; `make check-runtime-contract` is its focused gate. ## Validate a change Run checks for the affected boundary while developing. The repository -[required checks](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#required-checks) define completion, including +[required checks](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#required-checks) define completion, including `make check` and any changed native component's real acceptance. | Change | Focused validation | | --- | --- | -| Core handlers, persistence or clients | `make check-agents-api` | +| Core handlers, persistence or clients | `make check-core` | | SQL queries | `make sqlc-generate`, inspect generated files, then `make check-sqlc` | | Handler annotations or API contract | `make openapi`, inspect all three namespace schemas | | Shared Runtime protocol | `make check-runtime-contract` | @@ -169,13 +169,13 @@ with `AGENTS_FIXTURE_PORT` and `AGENTS_WEB_PORT` when running parallel validatio Keep databases, ports and containers separate between validation workers. Compilation, fixture success and live model/provider acceptance establish different facts; report skipped or unavailable checks explicitly. Follow the -[independent blind review workflow](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#review) +[independent blind review workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#review) after validation. ## Change documentation Edit the source that owns the subject, using the -[documentation ownership map](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#documentation-ownership). +[documentation ownership map](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#documentation-ownership). User guides explain a workflow and link to detailed contracts. Contributor rules explain ownership and required checks. Semantic contracts document exact behavior, implementation gaps and evidence. Avoid copying the same rule into all three. @@ -183,9 +183,9 @@ implementation gaps and evidence. Avoid copying the same rule into all three. Update authored sources before regenerating the docs site. Adding a site page also requires a source entry and navigation entry; source/output hashes verify freshness. API references render the namespace-specific generated schemas. Follow -[the docs app workflow](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) for link, type, build and browser +[the docs app workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/docs/README.md) for link, type, build and browser checks. The release bundle has a separate explicit documentation list in `scripts/core-distribution-manifest.py`; changing a bundled path or heading must also pass its relative-link and anchor checks. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/development.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/development.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 47f18b741..a3fc6914f 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,19 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted Session may instead select its exact canonical physical directory. The -[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +[recorded deployment qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md) retains its historical source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). +[sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md) provide reusable preparation; the Core extension also applies their execution configuration to user-managed machines; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md) provide reusable preparation; the Core extension also applies their execution configuration to user-managed machines; +[Files](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -33,7 +33,7 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile @@ -41,8 +41,8 @@ An explicitly configured default managed Docker provider enables `type=openai_ho for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its native adapter. The outer Environment provides managed isolation; the daemon adds no inner sandbox. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -63,7 +63,7 @@ rejects explicitly, including null and empty lists, and never invokes apt, sudo or elevated execution. Package responses include the official required `system: []` field as empty metadata; it does not enable installation. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -83,11 +83,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -188,7 +188,7 @@ compute replacement; never silently move a bound Session or replay unknown work. Self-hosted compute/files remain caller-owned, with explicit cleanup separate from Session deletion. The full Environment implementation remains pending; follow the [pinned contract and acceptance sequence](/environments-and-files) -and the [workspace placement map](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/workspace-placement.md). +and the [workspace placement map](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/workspace-placement.md). For co-location, qualify the outer deployment boundary and the shared Runtime lifecycle. Native tools use the starting account's permissions; do not claim a daemon or harness sandbox. Host operators choose their own outer isolation. @@ -696,7 +696,7 @@ silently expanding it. Codex and Claude preserve native allowlists and initializ required servers before releasing native input, including cold recovery. Public MCP with native Subagents remains unqualified. Nonempty literal HTTP headers, request metadata and public stdio declarations remain unsupported. -See [public MCP qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, +See [public MCP qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, platform and infrastructure coverage; admission support is not a claim of complete cross-platform/provider qualification. @@ -713,7 +713,7 @@ first-frame identities and cross-checks completed native results for both transp Reuse existing observation and cancellation settlement; never fabricate a delayed start event, guess normalized identities or add a registry of our own. -See [capability qualification](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md) for +See [capability qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md) for real model evidence and the remaining public-origin and image gaps. ## Contract inventory @@ -851,4 +851,4 @@ Session reads the current source. Harness profiles may reference the Runtime-own environment file, but must not persist copies of its values. A missing or invalid explicitly configured file fails preparation. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/examples.mdx b/apps/docs/content/docs/examples.mdx index d758b0eb1..ee1f2b5a5 100644 --- a/apps/docs/content/docs/examples.mdx +++ b/apps/docs/content/docs/examples.mdx @@ -16,7 +16,7 @@ A small single-user product built on Core. You save model providers, Skills and servers, combine them into Agents, then start Sessions in a managed sandbox, with no workspace, or on your own machine. -Source: [`example/parsar`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/README.md). +Source: [`example/parsar`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/README.md). ### Run it @@ -30,7 +30,7 @@ pnpm install --frozen-lockfile pnpm --filter @oac/parsar-example dev ``` -Open <http://127.0.0.1:18180>. The example [README](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/README.md) covers +Open <http://127.0.0.1:18180>. The example [README](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/README.md) covers storage, a production build, tests and connecting your own machine. ### What to look at @@ -39,15 +39,15 @@ Each feature maps to one part of the API. Read the code next to the guide sectio | Feature | API used | Guide | Code | | --- | --- | --- | --- | -| Server-side key and headers | Bearer key, `OpenAI-Beta`, route allowlist | [Before you start](/sessions#before-you-start) | [`server.mjs`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server.mjs) | -| Agents with a harness and model | `x_agents_core.harness`, `model_provider` | [Core extensions](/sessions#core-extensions-x_agents_core) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | -| Sessions on three runtimes | `openai_hosted`, `none`, `self_hosted` with `workspace_directory` | [Create a Session](/sessions#create-a-session) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | -| Crash-safe Session creation | `Idempotency-Key`, recovery from a lost response | [Idempotency](/sessions#idempotency) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | -| Live replies | Event stream, text deltas, reconnect and reconcile | [Stream events](/sessions#stream-events) | [`src/lib/live-session.ts`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/lib/live-session.ts) | -| Follow-ups and cancel | Input events with idempotency keys | [Send input](/sessions#send-input) | [`src/Composer.tsx`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/Composer.tsx) | -| History | Paging Turns and Items | [Pagination](/sessions#pagination) | [`src/lib/api.ts`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/lib/api.ts) | -| Skills and versions | `/skills`, default version | [Skills](/sessions#skills) | [`src/Skills.tsx`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/Skills.tsx) | -| Connect your own machine | Environment status, executor credential | [Self-hosted execution](/self-hosted-execution) | [`src/ConnectMachine.tsx`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/ConnectMachine.tsx) | +| Server-side key and headers | Bearer key, `OpenAI-Beta`, route allowlist | [Before you start](/sessions#before-you-start) | [`server.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server.mjs) | +| Agents with a harness and model | `x_agents_core.harness`, `model_provider` | [Core extensions](/sessions#core-extensions-x_agents_core) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | +| Sessions on three runtimes | `openai_hosted`, `none`, `self_hosted` with `workspace_directory` | [Create a Session](/sessions#create-a-session) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | +| Crash-safe Session creation | `Idempotency-Key`, recovery from a lost response | [Idempotency](/sessions#idempotency) | [`server/sessions.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/server/sessions.mjs) | +| Live replies | Event stream, text deltas, reconnect and reconcile | [Stream events](/sessions#stream-events) | [`src/lib/live-session.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/lib/live-session.ts) | +| Follow-ups and cancel | Input events with idempotency keys | [Send input](/sessions#send-input) | [`src/Composer.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/Composer.tsx) | +| History | Paging Turns and Items | [Pagination](/sessions#pagination) | [`src/lib/api.ts`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/lib/api.ts) | +| Skills and versions | `/skills`, default version | [Skills](/sessions#skills) | [`src/Skills.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/Skills.tsx) | +| Connect your own machine | Environment status, executor credential | [Self-hosted execution](/self-hosted-execution) | [`src/ConnectMachine.tsx`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/parsar/src/ConnectMachine.tsx) | ### Not covered @@ -57,10 +57,10 @@ machine, and managed Skills or templates on your own machine ## Add an example -Put it in its own directory under [`example/`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/README.md) with a README +Put it in its own directory under [`example/`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/example/README.md) with a README covering how to run it and which API features it shows, then add a row to the table above. Keep examples on the public API with a Project API key: never the Core key or private routes. Repository rules for examples are in -[CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#repository-boundary). +[CONTRIBUTING.md](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#repository-boundary). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/examples.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/examples.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 2474c102d..2c14b3278 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -5,7 +5,7 @@ description: "Applications call the public API; the administrator browser calls ![Application, administration and machine credential boundaries](/images/architecture.svg) -The console server (`services/core-console`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. +The console server (`services/web`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. ## Request boundary @@ -48,7 +48,7 @@ Every request except `/healthz`, `/v1` and `/api/v1` must pass these checks firs 2. **Safe request.** The path must start with `/` and contain no `%`, backslash, NUL, dot segment or empty segment. Absolute-form request targets, `CONNECT`, `TRACE` and any request with an `Upgrade` header get 400. A request can therefore never leave `/core/v1` on Core, and the console carries no WebSocket. 3. **Sign-in.** Paths that need sign-in answer 401 without a valid session cookie. -Under `/core`, these failures use the Core error envelope with the codes in [console-generated failures](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md#console-generated-failures); elsewhere they return `{"error": "…"}`, or plain text for an unsafe request. Every response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: frame-ancestors 'none'`. +Under `/core`, these failures use the Core error envelope with the codes in [console-generated failures](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md#console-generated-failures); elsewhere they return `{"error": "…"}`, or plain text for an unsafe request. Every response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: frame-ancestors 'none'`. ## Forwarding to Core @@ -58,13 +58,13 @@ On the way to Core, the console: - removes the browser's `Authorization`, `Proxy-Authorization`, `Cookie`, `Origin` and `Referer` headers; - sends `Authorization: Bearer `; -- sets `X-Core-Console-Actor: console`, replacing any value the browser sent. Core records it as a display-only audit label ([administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)); +- sets `X-Core-Console-Actor: console`, replacing any value the browser sent. Core records it as a display-only audit label ([administrator API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)); - ignores ambient HTTP proxy settings, so the Core key reaches only the configured Core; - streams responses without buffering. On the way back, it removes `Set-Cookie`, `WWW-Authenticate`, `Location`, `Refresh` and every `Access-Control-*` header. A redirect from Core, or a failed connection to Core, becomes 502 `core_unreachable`. -The console never retries a request. Browser code calls `/core/v1` through the typed clients in [`packages/agents-client`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/README.md); [console API usage](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and writes. +The console never retries a request. Browser code calls `/core/v1` through the typed clients in [`packages/agents-client`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/agents-client/README.md); [console API usage](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-api-usage.md) lists what each page reads and writes. ## Sign-in @@ -111,7 +111,7 @@ The status has `supported`, `state` (`unconfigured`, `checking`, `applying`, `re Without `OAC_WEB_INSTALLATION_SOCKET` (external reverse proxy installations), `GET` reports `supported: false` and `POST` returns 400 `domain_setup_unavailable`. An unreachable installer or an invalid answer returns 502 `installation_unreachable`. -The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https)). +The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/deploy/install/README.md#managed-https)). `OAC_WEB_BOOTSTRAP=1`, which the installer sets while no public URL is configured, lets the console also accept plain HTTP requests addressed to a literal IP address, treating `http://` as the origin, so an operator can sign in through the server's IP address. Host names still require `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach the console. @@ -144,4 +144,4 @@ After installing or changing the console, check: A sign-in failure belongs to the console. A 401 from Core on a signed-in request means the console's Core key does not match Core's digest, or the console reaches the wrong Core. The [troubleshooting table](/troubleshooting#troubleshooting) covers the common symptoms. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-server.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/web/console-server.md) diff --git a/apps/docs/content/docs/harness-onboarding.mdx b/apps/docs/content/docs/harness-onboarding.mdx index afc56ea72..9f77c3592 100644 --- a/apps/docs/content/docs/harness-onboarding.mdx +++ b/apps/docs/content/docs/harness-onboarding.mdx @@ -7,13 +7,13 @@ A **Harness** is a native agent engine (Codex, Claude Code, MiniMax Code) that runs the model/tool loop. A **Harness adapter** translates the common Core–Runtime execution contract into that engine's SDK or protocol. This guide is the single numbered path for adding one. Qualification evidence and the -per-operation support table live in [Harness integration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md). +per-operation support table live in [Harness integration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md). Start from two entry points: -- [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/harnessconfig/harness.go): +- [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/harnessconfig/harness.go): the shared model configuration contract (declarations and preparation). -- [`agent/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/harness.go): the +- [`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/harness.go): the execution lifecycle, explicit extension contracts and registration methods. @@ -33,12 +33,12 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Component | Responsibility | Location | | --- | --- | --- | -| Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/agents-api` | -| Runtime | Authenticated connection, shared capability preparation and common Executor/Turn lifecycle | `apps/parsar-daemon/internal/dispatch` | -| Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/parsar-daemon/internal/agent/` | +| Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/core` | +| Runtime | Authenticated connection, shared capability preparation and common Executor/Turn lifecycle | `apps/daemon/internal/dispatch` | +| Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/daemon/internal/agent/` | | Harness | Native model/tool loop and history | Pinned SDK or executable | -| Service profile | Pure validation of qualified operations and placements | `services/agents-api/internal/engine` | -| Registration | Installed factories and verified capability declarations | `apps/parsar-daemon/internal/cli` | +| Service profile | Pure validation of qualified operations and placements | `services/core/internal/engine` | +| Registration | Installed factories and verified capability declarations | `apps/daemon/internal/cli` | An Environment supplies execution resources. Managed Docker, E2B and user-managed machines differ in provisioning and connection; their connected Runtime uses this @@ -60,22 +60,22 @@ model communication configuration, not Turn scheduling or native process ownersh 1. **Pin the native source.** Record the upstream package version and source revision and document the native entry point next to the adapter. -2. **Implement the adapter** in `apps/parsar-daemon/internal/agent/`: an +2. **Implement the adapter** in `apps/daemon/internal/agent/`: an `ExecutorFactory`, an `Executor` and a `Turn`. See [Required adapter interfaces](#required-adapter-interfaces). Reuse shared process, credential/configuration and local workspace helpers. -3. **Register the kind in the Runtime** in `apps/parsar-daemon/internal/cli`. +3. **Register the kind in the Runtime** in `apps/daemon/internal/cli`. See [Register the adapter](#register-the-adapter). 4. **Add the service profile and one catalog entry.** See [Add the engine to Core](#add-the-engine-to-core). 5. **Package native prerequisites.** Add a Runtime image under - `services/agents-api/deploy/` and, optionally, + `services/core/deploy/` and, optionally, [native installer participation](#native-installer-participation). 6. **Enable and select the engine** through [engine selection](#engine-selection). 7. **Qualify it.** Run the synthetic integration test, then the real acceptance in - [Harness integration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#acceptance-checklist). Record results in - the [qualification table](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#current-qualified-operations). + [Harness integration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#acceptance-checklist). Record results in + the [qualification table](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#current-qualified-operations). Implement the mandatory text lifecycle and explicitly handle every extension. Qualify supported extensions one at a time; an unqualified extension returns @@ -112,13 +112,13 @@ limitations into the shared Core protocol. ## Native model configuration -[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/harnessconfig/harness.go) owns +[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each adapter supplies one `Configuration` to Core's composition and Runtime's `RegisterKind`. All three Runtime entry paths validate through that declaration before native side effects: direct factory, preparation and Executor. Registry wrappers retain the declaration alongside the factory. Lifecycle and cleanup ownership remain in -[`agent/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/harness.go). +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/harness.go). The shared wire object is `proto.HarnessConfig`. A supplied `model` must be a nonempty string, and an explicit `model_provider` @@ -145,7 +145,7 @@ Native protocol and parameter declarations also feed Core administration's small configuration-support descriptor. Its ordered `protocols` list is the sole source for accepted protocols and the default (the first entry). Core and Runtime reject unsupported combinations through the same declaration. The current protocol -matrix belongs to [model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence). +matrix belongs to [model execution](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence). Adapters connect directly through native configuration; they must not introduce a model API proxy or protocol converter. Follow the per-input capability requirements in the execution lifecycle contract; do not add a second model capability registry @@ -154,12 +154,12 @@ model support remain separate facts. Claude's private bridge receives compiled native options and performs structural checks only, not a second copy of the declaration's rules. Planned fields and -ownership are in the [unified model configuration design](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-configuration-design.md). +ownership are in the [unified model configuration design](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-configuration-design.md). ## Required adapter interfaces -[`agent/harness.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/harness.go) is the +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/harness.go) is the canonical interface entry point. Its required lifecycle is `ExecutorFactory`, `Executor`, `Turn` (including `DurableSteerer`) and `TurnSettlement`. Required methods must perform their native obligations; returning Unsupported is not an @@ -186,7 +186,7 @@ Permission and user-choice responses use the explicit extension interfaces below ## Events, inputs and optional capabilities -Use [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto) for neutral +Use [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto) for neutral requests, events and receipts. Each Turn emits only its own events with its Run ID, in order, and one terminal outcome. Native IDs and usage must be observed rather than invented. Capture the originating Turn before asynchronous native callbacks; @@ -250,8 +250,8 @@ for automatic replay. Registration is static and requires a build; dynamic plugins are outside this contract. The methods live in `agent/harness.go`, and built-in adapters call them -from [`cli/agent_registration.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/cli/agent_registration.go) -(Codex, MiniMax Code) and [`cli/claude_sdk.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/cli/claude_sdk.go) +from [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/cli/agent_registration.go) +(Codex, MiniMax Code) and [`cli/claude_sdk.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/cli/claude_sdk.go) (Claude Code). | Order | Method | Registers | @@ -287,19 +287,19 @@ Runtime registration does not grant Core qualification; that belongs to the service profile. The runnable test-only example is -[`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/testdata/onboarding/main.go). +[`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/testdata/onboarding/main.go). It registers a text-only synthetic Harness, demonstrates a Session-owned Executor, fresh Turns, durable steering, cancellation and history binding, and is never shipped as a real engine. ## Add the engine to Core -Core recognizes the [built-in Harness registrations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-catalog.md). +Core recognizes the [built-in Harness registrations](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-catalog.md). Add one entry to `internal/harnessconfig/builtin/catalog.json` with: - the public `kind` and display `label`; - the model `configuration` package under `internal/harnessconfig`; -- the `profile` constructor under `services/agents-api/internal/engine`. +- the `profile` constructor under `services/core/internal/engine`. Implement the profile constructor, then run `make generate-harness-catalog`. This generates the model configuration registry, Core profile catalog, client @@ -330,7 +330,7 @@ mutable global registration or compatibility fallback. ## Engine selection -The [Harness selection contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-selection.md) owns the public selector, +The [Harness selection contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-selection.md) owns the public selector, deployment enablement, defaults and immutable Session binding. This guide adds no second selector or fallback rule. @@ -350,14 +350,14 @@ with Unsupported and record the gap. Never advertise a capability to bypass sele Structured-output adapters consume `ExecutionControls.OutputFormat` and publish confirmed native output through the existing Message contract. Register public qualification separately from the Runtime capability; see the -[structured-output boundary](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/structured-output.md). No Core engine-name branch is required. +[structured-output boundary](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/structured-output.md). No Core engine-name branch is required. Initial requests, `Executor.StartTurn` and steering consume the same ordered `proto.MessageInput`. Text-only adapters use `TextOnly()` to reject images without discarding content. Image adapters translate each part natively and acknowledge an active batch only after all its messages are applied. Register Runtime `MessageImages` and qualify the engine profile’s `MessageImages` separately; see the -[message-input contract and real acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). +[message-input contract and real acceptance](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). Hosted workspace execution additionally requires verified preparation, workspace reads/output export, network behavior and credential/history isolation. Reuse the @@ -385,7 +385,7 @@ transport support; never infer it from model names or silently degrade input. ## Optional Subagent observations A harness that supports the Subagent resource reads implements the existing -[neutral observation contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/subagents.md#common-adapter-contract). It reports +[neutral observation contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/subagents.md#common-adapter-contract). It reports verified child identity, lifecycle effects and owned Turn/Item history through the authenticated Run, then qualifies those facts with real execution. It does not add routes, storage branches or a harness-specific Core scheduler. Report @@ -407,10 +407,10 @@ adapter tests must cover actual native semantics, not only method presence. | MiniMax native history binding | `mcode/session_test.go` | | Explicit refusals without native effects or fabricated results | Each adapter's `unsupported_test.go` | -These paths are relative to `apps/parsar-daemon/internal/agent`. Controlled native +These paths are relative to `apps/daemon/internal/agent`. Controlled native transport fixtures establish failure and ownership behavior; they are not live model qualification. Preserve the separate native acceptance requirements in -[Harness integration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#acceptance-checklist). +[Harness integration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#acceptance-checklist). ## Native installer participation @@ -459,8 +459,8 @@ Use these adapters as implementation references after choosing a native API: | Harness | Adapter | Native transport | Runtime guide | | --- | --- | --- | --- | -| Codex | [`agent/codex`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/codex/README.md) | -| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/claude-sdk-adapter/README.md) | [Claude Runtime](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/claude/README.md) | -| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/mcode) | ACP and native workspace companion | [MiniMax Code Runtime](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/mcode/README.md) | +| Codex | [`agent/codex`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/codex/README.md) | +| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/packages/claude-sdk-adapter/README.md) | [Claude Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/claude/README.md) | +| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/mcode) | ACP and native workspace companion | [MiniMax Code Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/mcode/README.md) | -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-onboarding.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-onboarding.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 5b52c7152..eb780dc33 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -5,7 +5,7 @@ description: "Add and remove Docker or microsandbox nodes; diagnose connection a A node is a Linux host that runs sandboxes for Core-hosted Sessions when the sandbox backend is Docker or microsandbox. Core places each new Session on a node with free capacity; the node creates the sandbox, and the sandbox connects back to Core. E2B needs no nodes. Machines that applications connect for their own Sessions are [self-hosted executors](/self-hosted-execution), not nodes. -You add a node by generating a command in Web and running it on the host. The [sandbox deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) defines the sandbox settings and their change rules, and the [node protocol](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md) defines how nodes prepare and keep Runtime generations. +You add a node by generating a command in Web and running it on the host. The [sandbox deployment contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) defines the sandbox settings and their change rules, and the [node protocol](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md) defines how nodes prepare and keep Runtime generations. ## Before you add a node @@ -106,7 +106,7 @@ Sandbox settings apply to the whole installation; nodes follow them. - **Size, Runtime release, E2B key or template build.** Open **System** → **Manage sandbox configuration** → **Change resources**, edit and save. Existing sandboxes keep their configuration. Each node prepares the new one while it keeps serving the old one, and **Nodes** shows its progress: **Preparing target**, **Ready for target** or **Preparation failed** with the [reason](#readiness-codes). Core places new Sessions on nodes ready for the new configuration first, and on nodes still serving an older one when those have no room. E2B changes apply at once. - **Backend.** On the same page, choose **Reset deployment**. Auto reset archives idle hosted Sessions at once and lets running work finish until the deadline you set; force cancels it now. Offline nodes must come back so Core can confirm their cleanup. When the reset completes, Core has retired every node and unused command: set up the new backend, then add nodes again. **Cancel reset** stops the remaining work; archived Sessions stay archived. -The [reset contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#generation-ownership-and-rollout) describes what reset archives and keeps. To archive a single hosted Session, use the [Core API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md#administrative-session-archive). +The [reset contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#generation-ownership-and-rollout) describes what reset archives and keeps. To archive a single hosted Session, use the [Core API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md#administrative-session-archive). ## Remove a node @@ -201,4 +201,4 @@ A new group membership applies only to a new process. Restart the node service: | Core still lists this node | Remove it on the Nodes page first | | Core no longer accepts this node | It was removed, or a reset retired it. Uninstall it, then add the host with a new command | -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index ad10cbf9d..00eea342c 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -3,7 +3,7 @@ title: "OpenAgentCore documentation" description: "Install Core and Web, create a Project API key, and add execution capacity." --- -OpenAgentCore runs AI agents on your own infrastructure behind the OpenAI Agents API. The [architecture overview](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/architecture.md) explains its parts. Pick the guides for your role. +OpenAgentCore runs AI agents on your own infrastructure behind the OpenAI Agents API. The [architecture overview](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/architecture.md) explains its parts. Pick the guides for your role. ## Operators @@ -31,6 +31,6 @@ Application developers call the API with a Project API key, and can run Sessions | [Examples](/examples) | Complete applications built on the API | | [API index](/public-api) | All three namespaces and their credentials | -Contributors start with the [developer guide](/development) and [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md). +Contributors start with the [developer guide](/development) and [CONTRIBUTING.md](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install-options.mdx b/apps/docs/content/docs/install-options.mdx index 29db5c30f..be0beba1f 100644 --- a/apps/docs/content/docs/install-options.mdx +++ b/apps/docs/content/docs/install-options.mdx @@ -62,11 +62,11 @@ An installation runs its sandboxes on exactly one backend: | `e2b` | E2B's cloud, sized by your template build | Nothing: E2B needs no nodes | | `none` | Nothing yet | Choose in Web under **System** → **Manage sandbox configuration** | -Docker and microsandbox start at the Standard size in Web's [`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/features/sandbox/standard-sizes.json). If Core refuses the choice, for example because E2B rejects the key, the installer prints Core's message and exits; the services keep running and you choose the backend in Web. To change the backend or size later, see [change the sandbox configuration](/hosted-providers#change-the-sandbox-configuration). +Docker and microsandbox start at the Standard size in Web's [`standard-sizes.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/web/src/features/sandbox/standard-sizes.json). If Core refuses the choice, for example because E2B rejects the key, the installer prints Core's message and exits; the services keep running and you choose the backend in Web. To change the backend or size later, see [change the sandbox configuration](/hosted-providers#change-the-sandbox-configuration). **Docker** shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](/hosted-providers#what-the-installer-sets-up). Choose it only for trusted workloads or hosts without KVM. The installer asks for confirmation (default No); without a terminal, pass `--accept-docker-risks`. -**E2B** needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud, so pass `--public-url`. Prepare the template build with the [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/e2b/README.md), then: +**E2B** needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud, so pass `--public-url`. Prepare the template build with the [E2B guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/e2b/README.md), then: ```sh ./install.sh --public-url https://core.example --sandbox e2b \ @@ -306,4 +306,4 @@ Copy `secrets/core.key` from the Core host with mode `0600`, then delete `$HOME/ Transfer the release's `*-linux-amd64-offline.tar.gz` and its `.sha256` file, verify and extract them, then run the bundled `./install.sh`. The offline bundle also carries the node and Runtime files, so Web serves them to nodes without release access. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/install-options.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/install-options.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index e4713293e..31e53127f 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -28,13 +28,13 @@ The Core host needs no KVM; nodes that run microsandbox do. ## Install ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` If DNS already points to this host, pass the address to set up HTTPS during installation instead of in step 4: ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash -s -- --public-url https://core.example +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash -s -- --public-url https://core.example ``` The script picks the latest stable release, verifies its checksum and runs the bundled installer, which: @@ -92,4 +92,4 @@ Sessions need somewhere to run: Day-to-day operation, backups and upgrades are in [Operations](/troubleshooting). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 16d1ad521..83eb42560 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index f3de2ad0b..748161b2e 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -10,8 +10,8 @@ public release content. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`, `harness_config`, Session `installation`) | [Agents API guide](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core issues for hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node routes](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core issues for hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node routes](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes), [executor credentials](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in @@ -26,7 +26,7 @@ on Core's loopback port. Details: [Web and Core](/admin-api). ## Public API Applications call `/v1` with a Project API key. The routes are exactly the 58 pairs -in [upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json). The +in [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json). The [Agents API guide](/sessions) explains every resource with SDK and HTTP examples. @@ -58,27 +58,27 @@ core() { # core METHOD PATH [JSON body] | Archive a Project (revokes all keys) | `core POST /projects/$PROJECT_ID/archive` | | See harnesses and their default models | `core GET /harnesses` | | Set Codex's default model | `core PUT /harnesses/codex/model-configuration '{"model": "your-model-id", "model_provider": {"protocol": "responses", "base_url": "https://provider.example/v1", "api_key": "sk-..."}}'` | -| Issue an executor credential | See [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#core-key-routes) | +| Issue an executor credential | See [executor credentials](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#core-key-routes) | | Installation facts, including the API base URL | `core GET /installation` | -Errors use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md). +Errors use the [Core error envelope](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md). ### All routes | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [nodes guide](/hosted-providers), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [nodes guide](/hosted-providers), [node host history](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -87,37 +87,37 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node routes](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node routes](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node routes](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes), [machine OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node routes](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md#authority-and-routes) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. @@ -129,7 +129,7 @@ Creating or reading a `self_hosted` Session returns short-lived install commands Machine installers use `POST /api/v1/agent-daemon/installation` and its `/claim` subroute with the installation Bearer authorization. Qualified artifacts under `/api/v1/agent-daemon/install/{version}/` are public, immutable release content. The -[installation grant](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#installation-grant) +[installation grant](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#installation-grant) owns expiry, retry and credential ownership; the [self-hosted guide](/self-hosted-execution#platforms) lists platforms. @@ -138,4 +138,4 @@ browser session and same-origin checks. It delegates only domain setup to the installer, with the server-held Core key over a private Unix socket; it is not part of the Agents API or Core management API. See [console domain setup](/bootstrap-projects-keys#domain-setup). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index a75d47b12..e1f980889 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -8,7 +8,7 @@ This walkthrough takes you from a Project API key to an agent that has created a - a Project API key and the API base URL, from your administrator ([Issue a Project API key](/install#issue-a-project-api-key)); - a ready node or E2B backend, so Core has somewhere to run the agent ([Nodes](/hosted-providers)); - Python 3.9 or newer; -- a model: the installation's default model, or your own provider's model ID, base URL and API key. [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence) says which one a Session uses and which protocols each harness speaks. +- a model: the installation's default model, or your own provider's model ID, base URL and API key. [Model execution](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md#saved-defaults-and-precedence) says which one a Session uses and which protocols each harness speaks. The example uses Codex, whose model provider must speak the OpenAI Responses API. @@ -114,4 +114,4 @@ Success is a `completed` Turn whose Items describe the new file. A timeout neith | Run the agent on your own machine | [Self-hosted execution](/self-hosted-execution) | | See a complete application | [Examples](/examples) | -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/runtime-bootstrap.mdx b/apps/docs/content/docs/runtime-bootstrap.mdx index 4dff42d41..a147abee9 100644 --- a/apps/docs/content/docs/runtime-bootstrap.mdx +++ b/apps/docs/content/docs/runtime-bootstrap.mdx @@ -4,7 +4,7 @@ description: "Provider delivery of Runtime-owned connection input." --- This document owns the Provider-to-Runtime startup boundary. The input type and -validator live in [runtimebootstrap](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/runtimebootstrap/bootstrap.go). +validator live in [runtimebootstrap](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/runtimebootstrap/bootstrap.go). Providers must not read or write Runtime's private authentication store. ## Launch input @@ -54,8 +54,8 @@ bootstrap-file fallback. Both paths enter the same Runtime execution loop. ## Verification -`go test ./internal/runtimebootstrap ./apps/parsar-daemon/internal/cli` covers the +`go test ./internal/runtimebootstrap ./apps/daemon/internal/cli` covers the input contract, credential-source exclusivity and restart behavior. Provider tests verify delivery and permissions without relying on private Runtime storage. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-bootstrap.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-bootstrap.md) diff --git a/apps/docs/content/docs/runtime-protocol.mdx b/apps/docs/content/docs/runtime-protocol.mdx index 9e144911c..d83bbc2ec 100644 --- a/apps/docs/content/docs/runtime-protocol.mdx +++ b/apps/docs/content/docs/runtime-protocol.mdx @@ -5,10 +5,10 @@ description: "Runtime lifecycle, messages, receipts and recovery." This is the integration entry point for a Runtime that executes work for Core. The wire definitions live once in -[`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto); -Core's [gateway](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/gateway) and the reference Runtime's -[dispatcher](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/dispatch) both use them. -The [machine HTTP API](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) describes +[`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto); +Core's [gateway](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/gateway) and the reference Runtime's +[dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/dispatch) both use them. +The [machine HTTP API](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) describes registration and connection endpoints. This document defines the meaning and ordering of the messages after connection; it does not replace the typed payloads. @@ -46,7 +46,7 @@ preparation is not proof of containment. 5. Dispatch ordered JSON envelopes over the connection. Heartbeats establish liveness only, not execution progress or a receipt for earlier messages. -The wire version is [`proto.Version`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/version.go), +The wire version is [`proto.Version`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/version.go), independent of the Runtime build version reported in heartbeats. Core accepts only an exact match, including the patch component. A mismatch returns HTTP 426 `incompatible_version` before dispatch; the daemon treats it as permanent and @@ -211,7 +211,7 @@ settlement rules across Turn boundaries and Executor closure. ## Envelope and identity Every data frame is one JSON -[`Envelope`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/envelope.go): +[`Envelope`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/envelope.go): `type`, type-dependent `id`, typed `payload`, and optional W3C `trace`. Trace is diagnostic correlation only; missing or invalid trace data creates a local trace and never changes ownership. Do not use a trace ID as a request ID. @@ -248,16 +248,16 @@ finite error categories. There is no parallel payload schema to keep in sync. | Core → Runtime | Runtime → Core | Definition | | --- | --- | --- | -| `runtime_prepare` | `runtime_prepare_result` | [Initialization and capability transfer](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/runtime_prepare.go) | -| `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/preparation.go) | -| `prompt_request`, `prompt_cancel`, `device_shutdown` | `delta`, `thinking`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat` | [Requests](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/inbound.go) | -| `permission_decision`, `prompt_for_user_choice_decision` | `permission_request`, `permission_cancel`, `prompt_for_user_choice`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/outbound.go), [interactions](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/inbound.go) | -| `prompt_steer` | `prompt_steer_ack` | [Active input receipts](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/steering.go) | -| `function_result` | `function_call`, `interaction_decision_ack` | [Function calls](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/functions.go) | -| `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_read.go), [write](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_write.go), [export](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_export.go) | -| `environment_quiesce`, `environment_resume` | `environment_quiesced`, `environment_resumed` | [Suspension fencing](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/suspend.go) | - -Initial and active input share [ordered MessageInput](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/message_input.go). +| `runtime_prepare` | `runtime_prepare_result` | [Initialization and capability transfer](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/runtime_prepare.go) | +| `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/preparation.go) | +| `prompt_request`, `prompt_cancel`, `device_shutdown` | `delta`, `thinking`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/inbound.go) | +| `permission_decision`, `prompt_for_user_choice_decision` | `permission_request`, `permission_cancel`, `prompt_for_user_choice`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/outbound.go), [interactions](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/inbound.go) | +| `prompt_steer` | `prompt_steer_ack` | [Active input receipts](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/steering.go) | +| `function_result` | `function_call`, `interaction_decision_ack` | [Function calls](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/functions.go) | +| `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_read.go), [write](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_write.go), [export](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/workspace_export.go) | +| `environment_quiesce`, `environment_resume` | `environment_quiesced`, `environment_resumed` | [Suspension fencing](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/suspend.go) | + +Initial and active input share [ordered MessageInput](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/agentdaemon/proto/message_input.go). Adapters preserve message/content order and explicitly reject unsupported content. Usage frames and the final usage snapshot replace earlier cumulative snapshots; do not add them. An absent measurement is unknown, not zero. @@ -283,7 +283,7 @@ Core-managed Docker `openai_hosted` and user-managed `self_hosted`. MiniMax images and remote URLs remain explicit implementation gaps. Workspace images reuse the existing preparation, active-input and workspace authority; they do not add a downloader, a mount or a separate execution lifecycle. Core -does not fetch or transform media. See [message input coverage](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). +does not fetch or transform media. See [message input coverage](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). ## Preparation and execution order @@ -428,7 +428,7 @@ truth and reconciles from confirmed facts. Runtime retains cleanup ownership until native work, input receipts, interactions and child work have settled. The public Turn status is a separate, existing projection: -[`execution/delivery.go`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/execution/delivery.go) +[`execution/delivery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/execution/delivery.go) records an unsuccessful orchestration attempt as `failed`, including `delivery_unknown` after an unconfirmed send and `event_stream_incomplete` after subscription failure. A closed subscription can replace the send reason @@ -520,10 +520,10 @@ observations cannot establish a current connection. Run `make check-runtime-contract` from the repository root. It exercises the shared wire validators, gateway, transport and dispatcher, plus -[real WebSocket contract scenarios](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/contracttest/wire_test.go) -using a controlled Harness adapter, plus the [observation-result regression](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/execution/runtime_protocol_test.go). It requires no model credentials or external +[real WebSocket contract scenarios](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/contracttest/wire_test.go) +using a controlled Harness adapter, plus the [observation-result regression](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/execution/runtime_protocol_test.go). It requires no model credentials or external sandbox. These tests are also included in `make check` through `check-go` and -`check-agents-api`. +`check-core`. The suite checks incompatible versions, preparation failure, cancellation settlement, connection loss without invented terminal events, reconnect without @@ -537,7 +537,7 @@ controlled-adapter test establishes the transport contract, not native Harness behavior, OS support, provider authentication or sandbox isolation. Update the shared types, this guide and the contract checks together when semantics change. -The reusable [Harness text assertions](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/contracttest/text.go) +The reusable [Harness text assertions](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/daemon/internal/agent/contracttest/text.go) accept any prepared Executor and a small fixture supplying deterministic normal, active and steering inputs. They check independent Turn streams, native owner and history continuity, durable write/application receipts, stale cancellation and @@ -568,4 +568,4 @@ installed MCP into transient effective bindings. See the [origin and credential contract](/environments-and-files#public-mcp-connection-origin) for supported combinations, native limits and failure ownership. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-protocol.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-protocol.md) diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx index c704602f4..fdb7e67a8 100644 --- a/apps/docs/content/docs/sandbox-provider.mdx +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -6,7 +6,7 @@ description: "Environment allocation, bootstrap and reclamation." A **Sandbox Provider** supplies the outer compute (an *Environment*) that a Core Runtime daemon runs in, plus the bounded bootstrap that starts it. This guide is the single start-to-finish path for adding one. The canonical interface is -[`SandboxProvider`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/sandbox_provider.go). +[`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/sandbox_provider.go). ## Before you start @@ -15,7 +15,7 @@ the single start-to-finish path for adding one. The canonical interface is | Environment | Durable execution place owned by Core; see [Environments](/environments-and-files) | | Allocation | One Core-owned compute lease for an Environment, identified by `Reference` | | Runtime | The daemon inside the Environment; it prepares capabilities and executes Turns | -| Deployment | The single deployment-wide provider selection; see [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) | +| Deployment | The single deployment-wide provider selection; see [Sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md) | Core owns durable Environment, allocation, placement and cleanup state. The Provider supplies compute and bootstrap only. Runtime prepares capabilities and @@ -37,10 +37,10 @@ connectivity; see [Runtime and outer isolation](/concepts#runtime-and-outer-isol 1. **Read the contract.** Implement the five required operations and explicitly handle all extension interfaces in [Implement the interface](#implement-the-interface). -2. **Write the adapter package** under `services/agents-api/internal/sandbox/` +2. **Write the adapter package** under `services/core/internal/sandbox/` (native SDK calls, ownership checks, identity translation, private config). Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. - Out-of-process helpers live in `services/agents-api/tools/-provider`. + Out-of-process helpers live in `services/core/tools/-provider`. 3. **Register the kind** once using [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. @@ -49,7 +49,7 @@ connectivity; see [Runtime and outer isolation](/concepts#runtime-and-outer-isol 5. **Run the contract suite** with `make check-sandbox-provider-contract`. See [Validate the integration](#validate-the-integration). 6. **Run native acceptance** against real compute, then `make check`. Record - evidence and limits in [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). + evidence and limits in [Sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). 7. **Document operator setup** next to the adapter, like the [reference adapters](#reference-adapters). @@ -110,7 +110,7 @@ only; callers use the declaration to decide whether an operation is supported. safe `UnsupportedError` for `ObserveBatch` permits per-target `Observe` calls. An unavailable service, timeout or other failure never triggers that fallback. Observation never renews, starts, prepares or stops compute; see the -[observation contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability.md). +[observation contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime-observability.md). Contract tests call every declared unsupported native method with no configured native client, require its matching error and zero result, and reject incomplete @@ -184,7 +184,7 @@ Persist operation IDs and provider-returned snapshot provenance unchanged. another capture or restore. `ResumeCompute` only thaws the retained source; it must not cold-start a stopped one. Cleanup targets the exact compute incarnation and snapshot, not whichever instance currently has the same display name. See -[the lifecycle implementation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/execution/runtime_compute.go) +[the lifecycle implementation](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/execution/runtime_compute.go) and its failure tests before advertising this capability. ### Four distinct readiness facts @@ -223,7 +223,7 @@ For a new implementation: Node proxy identity and checkpoint advertisement consume this same entry. The installer projection uses those registered policies and the common field bounds in `sandbox/deployment_contract.go`; regenerate it with - `go run ./services/agents-api/cmd/specification-contract -write`. + `go run ./services/core/cmd/specification-contract -write`. 4. If new configuration fields are necessary, extend the typed `sandbox.Selection` envelope and its dedicated encrypted persistence fields, API DTO and operator client. Do not replace typed configuration with unrestricted JSON. Field codecs @@ -279,12 +279,12 @@ cleanup can verify the `Reference` and installation. - Use the `io.oac.` prefix, for example `io.oac.installation` and `io.oac.tenant`. - Where the vendor rejects dotted keys, use `oac_` metadata keys (E2B). - Never accept older label names as a fallback; see - [Runtime names](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#openagentcore-runtime-names). + [Runtime names](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/CONTRIBUTING.md#openagentcore-runtime-names). ## Validate the integration Run `make check-sandbox-provider-contract` while developing. It runs the shared -[`contracttest`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/contracttest) suite through +[`contracttest`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/contracttest) suite through real adapter boundaries using controlled native failures, plus existing adapter and node transport tests. The same packages are included in `make check`. New adapters should call the public failure runner with native-side fixtures, @@ -299,7 +299,7 @@ binary migration. Direct in-process interfaces have no independent wire version. Node generation management is an explicit current hello capability, not another wire version. Fixed-configuration manual nodes use the same protocol and only serve their enrolled deployment generation. See the -[node contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md). +[node contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md). Run `make check` with its dedicated database before completion. Retain native acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, @@ -316,11 +316,11 @@ Environment provides isolation. | Kind | Adapter | Helper | Operator guide | | --- | --- | --- | --- | -| Docker (node) | [`sandbox/docker`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/docker) | Node proxy in [`sandbox/node`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/node) | [Nodes](/hosted-providers) | -| microsandbox (node) | [`sandbox/microsandbox`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tools/microsandbox-provider) | [`deploy/microsandbox`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/microsandbox/README.md) | -| E2B (direct) | [`sandbox/e2b`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/sandbox/e2b) | [`tools/e2b-provider`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tools/e2b-provider/README.md) | [`deploy/e2b`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/e2b/README.md) | +| Docker (node) | [`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/docker) | Node proxy in [`sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/node) | [Nodes](/hosted-providers) | +| microsandbox (node) | [`sandbox/microsandbox`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/tools/microsandbox-provider) | [`deploy/microsandbox`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/microsandbox/README.md) | +| E2B (direct) | [`sandbox/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/internal/sandbox/e2b) | [`tools/e2b-provider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/tools/e2b-provider/README.md) | [`deploy/e2b`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/core/deploy/e2b/README.md) | Provider selection and E2B setup are owned by -[Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). +[Sandbox deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/sandbox-provider.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/sandbox-provider.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index a07ff1842..b63eac510 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -17,7 +17,7 @@ The Session brings its own model provider; the installation default never applie | macOS arm64 | Supported | Supported | Supported | | Windows amd64 | Supported | Supported | Not supported | -The installer brings its own pinned Node.js and Harness versions (listed in [`scripts/build-native-installer.mjs`](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/scripts/build-native-installer.mjs)) and leaves other installations of those tools untouched. On a platform without a matching installer, the command fails. +The installer brings its own pinned Node.js and Harness versions (listed in [`scripts/build-native-installer.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/scripts/build-native-installer.mjs)) and leaves other installations of those tools untouched. On a platform without a matching installer, the command fails. The machine needs: @@ -60,7 +60,7 @@ No administrator privileges or Docker are needed. In Web, open the Session and copy the command under **Connect a host**. -Keep the command private: it carries a short-lived [installation grant](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#installation-grant) that claims the machine's credential. When it has expired, read the Session again or copy a fresh command from Web. +Keep the command private: it carries a short-lived [installation grant](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#installation-grant) that claims the machine's credential. When it has expired, read the Session again or copy a fresh command from Web. The installer reports three results: @@ -128,7 +128,7 @@ The installation's `bin/oac-daemon` finds its own installation. Use it for: | `oac-daemon logs -n 100`, `oac-daemon logs -f` | Print or follow the daemon log | | `oac-daemon stop` | Stop the daemon | -If you set `OAC_RUNTIME_HOME`, use the same value for every command. Check the connection under **Host connection** on the Session's page in Web, or with the [connection status](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#connection-status). +If you set `OAC_RUNTIME_HOME`, use the same value for every command. Check the connection under **Host connection** on the Session's page in Web, or with the [connection status](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#connection-status). To add a Harness, run the install command again (a fresh copy from Web if it has expired) with the same installation directory and the Harness to add. The installer checks the existing contents, adds only missing components and keeps the Harnesses already installed. Restart a running daemon afterwards so it discovers the new Harness. @@ -143,9 +143,9 @@ In Web, the Session's **Executor credentials** list the machine's credential: | **Rotate** | The credential gets a new secret; the old secret stops working at once. On a revoked credential the action is **Restore** | | **Revoke** | The credential stops working at once | -To reconnect after a rotation, stop the daemon with `oac-daemon stop`, replace the JSON at its configured credential-file path with the new credential, and run `oac-daemon start`. Do not run `install` again over the existing installation, and rotate the existing credential rather than issuing a second one, which [cannot connect](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#revoked-or-rotated-credential). +To reconnect after a rotation, stop the daemon with `oac-daemon stop`, replace the JSON at its configured credential-file path with the new credential, and run `oac-daemon start`. Do not run `install` again over the existing installation, and rotate the existing credential rather than issuing a second one, which [cannot connect](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#revoked-or-rotated-credential). -In an archived Project, credentials cannot be issued or rotated; revocation remains available. Operators can manage credentials with the Core key; see the [credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#core-key-routes). +In an archived Project, credentials cannot be issued or rotated; revocation remains available. Operators can manage credentials with the Core key; see the [credential contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md#core-key-routes). ## Install from an extracted distribution @@ -163,4 +163,4 @@ The same installer accepts an already extracted distribution and a credential fi Use the Session's `remote_url` and Environment ID. In PowerShell, run `.\oac-daemon.exe` with native absolute paths. This mode needs an existing workspace and does not start the daemon until you run `start`. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 54d35dcab..eee81bb29 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -61,10 +61,10 @@ keeps the key out of the process list. | [Skills](#skills) | `/skills` | Versioned capability bundles | | [Environment Templates](#environment-templates) | `/agents/environments/templates` | Reusable workspace setup | | [Vaults](#vaults) | `/vaults` | Write-only credentials for MCP servers | -| Subagents | `/agents/sessions/{id}/subagents` | Read-only child work; see [subagents](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/subagents.md) | +| Subagents | `/agents/sessions/{id}/subagents` | Read-only child work; see [subagents](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/subagents.md) | Core has exactly the routes of the pinned SDK, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json). It adds no +[upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/upstream-routes.json). It adds no route; its additions live in [`x_agents_core`](#core-extensions-x_agents_core). ## Conventions @@ -305,7 +305,7 @@ oac "/agents/sessions/$SESSION_ID/events" -H "Idempotency-Key: $KEY" -d '{ Images work with Codex and Claude Code wherever the Runtime supports them; MiniMax Code rejects them. The whole request is limited to 1 MiB. -- Full rules: [message input](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). +- Full rules: [message input](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/message-input.md). ### Cancel @@ -367,7 +367,7 @@ does both. `agent.session.created` first, and the stream ends at the first `idle` or `failed`. **Reconnecting:** resubscribe, then read Items and drop any you already have by ID. -Details: [history and events](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md). +Details: [history and events](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/history-events-usage.md). ## Turns and Items @@ -518,7 +518,7 @@ client.skills.versions.create(skill.id, files=[...], default=True) - Attach Skills to a Session through its `environment.skills` or a [template](#environment-templates). -Details: [Source Files and Skills](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/source-files.md). +Details: [Source Files and Skills](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/source-files.md). ## Environment Templates @@ -535,13 +535,13 @@ template = client.beta.agents.environments.templates.create( | Field | Meaning | | --- | --- | -| `network` | `access`: `enabled` (default), `disabled`, or `restricted` to 1–100 exact hosts in `allowed_domains`. A Session can only narrow it. Current Runtimes don't enforce `disabled` or `restricted`, so a Session that needs them is rejected; see [restricted network policy](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md#restricted-network-policy) | +| `network` | `access`: `enabled` (default), `disabled`, or `restricted` to 1–100 exact hosts in `allowed_domains`. A Session can only narrow it. Current Runtimes don't enforce `disabled` or `restricted`, so a Session that needs them is rejected; see [restricted network policy](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md#restricted-network-policy) | | `packages` | `npm` and `python` packages. `system` packages are rejected: preinstall them in the image or on the machine | | `setup_commands`, `env` | Run and set at preparation. Never returned by reads | | `files`, `skills`, `plugins` | Initial content. Up to 50 files, 10 MiB inline in total | A Session freezes the template when it starts. Details: -[Environment Templates](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md). +[Environment Templates](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md). ## Vaults @@ -583,7 +583,7 @@ Current rules: [public MCP connection origin](/environments-and-files#public-mcp | Packages | `packages.system` rejected | Per-operation status and harness differences are in the -[coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md). The exact schemas are in the -[public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/openapi.yaml). +[coverage record](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md). The exact schemas are in the +[public OpenAPI](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/openapi.yaml). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index c920476ca..945048cfc 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -95,7 +95,7 @@ core() { # core METHOD PATH [JSON body] | Set Codex's default model | `core PUT /harnesses/codex/model-configuration '{"model": "your-model-id", "model_provider": {"protocol": "responses", "base_url": "https://provider.example/v1", "api_key": "sk-..."}}'` | | Installation facts, including the API base URL | `core GET /installation` | -The [API index](/public-api#core-api) lists every route; errors use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md). +The [API index](/public-api#core-api) lists every route; errors use the [Core error envelope](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core-errors.md). ### Rotate the Core key @@ -184,4 +184,4 @@ Web signs administrators in with the Core key, checks the origin of every reques Sandboxes are the isolation boundary ([Runtime and outer isolation](/concepts#runtime-and-outer-isolation)). Docker sandboxes share the node's kernel, and a Docker node is [root-equivalent](/hosted-providers#what-the-installer-sets-up) on its host; microsandbox gives each sandbox a microVM with an explicit [network policy](/hosted-providers#what-the-installer-sets-up). Core itself has no Docker socket or KVM access. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/operations.md) diff --git a/apps/docs/content/docs/user-guide.mdx b/apps/docs/content/docs/user-guide.mdx index 4059308af..b06f94b42 100644 --- a/apps/docs/content/docs/user-guide.mdx +++ b/apps/docs/content/docs/user-guide.mdx @@ -43,7 +43,7 @@ The harness is the agent program that runs your Session. Set it in settings; see [native model configuration](/harness-onboarding#native-model-configuration). Selecting a harness doesn't make an unsupported model or operation work; see -[harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-selection.md). +[harness selection](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harness-selection.md). ### Which model provider a Session uses @@ -65,7 +65,7 @@ never merged: Self-hosted Sessions never use the default, because it holds the operator's key and your machine is outside the operator's control. A Session freezes its provider at creation; later changes affect only new Sessions. Full rules: -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/model-execution.md). ## Follow up and steer @@ -158,4 +158,4 @@ history; see [operating the installation](/self-hosted-execution#operate-the-ins A 401 usually means a key from the wrong namespace; see the [API index](/public-api). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/user-guide.md) +[Repository source](https://github.com/MiniMax-AI/OpenAgentCore/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/user-guide.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 1d586a695..9b51c413a 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -3,16 +3,16 @@ "sources": { "docs/getting-started/README.md": "d93c270e208fdf84edaf71827ed635a017be8cf67e0a4afef7279e3945d53173", "docs/design-principles.md": "334e0d477c255552f874f3ca8a2db603d0d07892ad7d58930bf8e3d06b86c636", - "docs/web/console-server.md": "e995515b80450f2964bbd8e0c850d05b9935ba65512f8817368ca87628eafb37", - "docs/getting-started/install.md": "16a77eeeefeb608e329df63318edcc9e111c37273c27c0994054ab5c659fa1bc", - "docs/getting-started/install-options.md": "51fb99f87310f137ee3842137f69d181d15547f1121b0ba43765f990ef1cbd25", + "docs/web/console-server.md": "974ccb12b6034ad970c8b02a42a3c495a14b460c56cc1ecea77345a2c11df4ed", + "docs/getting-started/install.md": "135c668c0439c3a3084a7eb03ac37ddb4525df1a07e30485b420c23c91a0a947", + "docs/getting-started/install-options.md": "3299f76b51a41a35f41448a05702a27f540c2976f4cf6a7c826d72b1da0b80a1", "docs/configuration.md": "ff24b556096841bb89df3747da04e57331346020f349d7208f708e6bbfab5589", "docs/getting-started/quickstart.md": "51374df1c86cc3376a92da4599915818d05b94863d728b234bd12be445d1156d", - "docs/api/README.md": "04ebcfdb1cc0fdca650a631508a667efb69499d228bae9b02ac2af44ed253af7", - "contracts/agents-api/execution-tools.md": "fe1e3cf471fe9c7ef04afa7230e6b7742fbf2146c74bd944a7a22d5d4d4197da", + "docs/api/README.md": "fff58d7789db361cda8353020703dd866d32ce0db88ad05da7d8ecd0cc25c332", + "contracts/agents-api/execution-tools.md": "a66c5ba217d532c64f3b041abf7f815692578f432656e64f516061987563089e", "docs/api/public-agent-api.md": "e1111aaf5215392178246969e281b930374b22ee380d529b08b2482836d6333d", "docs/examples.md": "c12c34adc5b2fa57c81602b23d8ecc8317596f9c5a4aedbf1f1fe934a08a94f5", - "contracts/agents-api/environments.md": "404cdc48ce09bb61729c7808bc2a79c5493ffbdb0f230f871d47160522798de8", + "contracts/agents-api/environments.md": "72d037ac95930c46c519e19e9d48db242f86d4ed1ac3579cb4fb2f52484da286", "docs/getting-started/nodes.md": "395d04a29b95a9299a2474d76955d65e66edebdfd5bf3d8ce798e1bbda223148", "docs/getting-started/self-hosted.md": "653a9132ea6bbc39186f7917fa1596027d091071172e6a6afd9f618fc1dab725", "docs/assets/console-overview-en.webp": "86c663ffb214b3e428eb09fc998f55c22a3d3706164216dc4386031728a7352c", @@ -22,40 +22,40 @@ "docs/getting-started/operations.md": "5cf3f4c6fa26b6445645c501ed53f78f450e9d9a31667166afebfaf02131f9a7", "docs/user-guide.md": "078ad930003dd333e65beb152dab11809b08d58dcc9d5d108c61f60dc3a320e2", "docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", - "docs/development.md": "f71297b08e51e939d84801bed2274a0a5cbcf265e8a3659b98bbaab949afeea5", - "contracts/agents-api/harness-onboarding.md": "03b069b0c2ae18248cf6b1d1c82b6c3a6cd74719746bd343acb128a86d2c67a2", - "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", - "docs/runtime-protocol.md": "4bce016e8ec239281969c84c8483044cf3247051eb062ec30d3effa00b661001", - "docs/sandbox-provider.md": "7c05d1799fa027897451032ee444ab01e444795030659da59ff33ac03d45ab18", + "docs/development.md": "0532097a1c1c7e92407712a1d9e2a820476a4e78a3de37f84f8718d211860c4d", + "contracts/agents-api/harness-onboarding.md": "4f05ac81ebfabfb8e860a92e25eaaaa39e08b6ba5011fb492c1935882646e340", + "docs/runtime-bootstrap.md": "4edf9c7863e4f0033063145371df605ce21facafd79eeeb3492826b7a05e2f80", + "docs/runtime-protocol.md": "b9d764ab7fc123b55f51aa750fa399fa9156448173f6f7d3a44ffe7b8dd4ce50", + "docs/sandbox-provider.md": "f46f408c6ee86d52a2c332978c0767052680fd98bb2b83d8b581ea739a9d9d10", "apps/docs/scripts/guides.json": "3b91ba5094ead91a719fa8260f5aad87e1e5aac7ecf9703b546fdef21772355b" }, "outputs": { - "content/docs/index.mdx": "55c6f5320b163a46dc74c705581cc34416766132b9fb10b0f5a1cf5832b46f2f", - "content/docs/concepts.mdx": "b9aceda2f72f3f1239e5518c3cecff8c9c0aa11a3eb622ddf9db6af2984a1abc", - "content/docs/execution-model.mdx": "faa01d7499f34fd1bacdf04860e63df0015084ab941ee9e157060c831e18266e", - "content/docs/install.mdx": "90cd9f76a0ef5116363c2d20ad21465171bd0dca55f9d5d9b0df443cc3c844b3", - "content/docs/install-options.mdx": "090f8840f5c164f41d6438b7b403c406ea1b7c0d695adbca3c3f06ba2193a5aa", - "content/docs/configure.mdx": "1613d7a2f5c57b832cf6336a4ab51852b76ac59b8be2ca7b2b3054c32e627c7c", - "content/docs/bootstrap-projects-keys.mdx": "5aa4b230ac8b8608d0e454bbc3badd128789ceb7acb9dd7996534a649caa6655", - "content/docs/quickstart.mdx": "e389d12e7417456494b67047fa5de922678634539154bd6486ff424b08344126", - "content/docs/public-api.mdx": "5caac0e2c857c6f887b903c7a9b6112320dce8081ca920f6ed2ccddaac91c403", - "content/docs/agents-and-tools.mdx": "3ca16d2e2ff1c759251fbdf6e071ff09a93d5efc7acaca36a48854597d4f9576", - "content/docs/sessions.mdx": "e8693563738f690c21be92e0ea09e925528bc85bea2c0fb4799c3f1c4074cfba", - "content/docs/examples.mdx": "5f1dde8043695c40edf775345159d93b2444d5ce6a109829b78678b0b5de0d46", - "content/docs/environments-and-files.mdx": "6a59efd3c4b2f37b389b3801c596efccef5128cae65d9b56b47cdd2e1a417b13", - "content/docs/hosted-providers.mdx": "da86d65fe1fa44f27600c3a0ea61e4954f894339724310be972d5e2bc03163e5", - "content/docs/self-hosted-execution.mdx": "a8e6500e41c666eacb2c75882b2b8ee0cf122fe19ea36f39ef89f5dcf17b68e1", + "content/docs/index.mdx": "d185ee4c3057995b059b5841e58a1ecdfcc1602c998e598303db977a3511c918", + "content/docs/concepts.mdx": "d280cc3bffeae6ce61efa46e38b797baf40ed6b96a0d84515f8110409f12bf5f", + "content/docs/execution-model.mdx": "7297e1aac1f80cce0d73b1e54503fdf5d151c39ea84d0e569c3dec0e2d036ea6", + "content/docs/install.mdx": "acb7dc71e35a67f2561ba7ff18f6399232f65d48af0978f44eb9b944de3082d3", + "content/docs/install-options.mdx": "686d0a4a0bcc3eec2d27cb3b32bf10c841cb234684c40bbcdce9b6d5450d93b3", + "content/docs/configure.mdx": "f25b4238ceaea39c76aa0b9670510461579ca4ba4b9c58eff58c8f61c3d440b2", + "content/docs/bootstrap-projects-keys.mdx": "663c985f1f1c76abada147f589b0577b3406cd8fb317df48167ca8d8ef213f53", + "content/docs/quickstart.mdx": "fb0b4171e31032bc14a013d6a2e57114c4a120c64ad1d8716cfa02bf21579487", + "content/docs/public-api.mdx": "288d52390e51dd28ede986a573fc61125b6136cfe2fd024e3b2e39ff2bc58aff", + "content/docs/agents-and-tools.mdx": "7f592336db8961b3bb385f425ba5335a8d30714e201d1bcfb6eff74ca7f63454", + "content/docs/sessions.mdx": "8262b0fac4b8f030da179189b29a2c458c36a6757d70cf6017e428d6b1c6dd7e", + "content/docs/examples.mdx": "cc266017e9874027003d234b29df3581ebc3caa5bd6fcd126c92be71fb7ab86f", + "content/docs/environments-and-files.mdx": "73b017cb26f2983bac2d3d9a8914e94ade02ec3aa2adfa8e6c7a8091197b9d6e", + "content/docs/hosted-providers.mdx": "56078aea1459895a8f11978f4bbabcd27527b97b06dfd27918c9c4667efd05d1", + "content/docs/self-hosted-execution.mdx": "1a7b67384d2e44d63211c7f1c9cc9775ffff18e647dc8d188ec08cea9724f1dc", "public/images/source/docs/assets/console-overview-en.webp": "86c663ffb214b3e428eb09fc998f55c22a3d3706164216dc4386031728a7352c", - "content/docs/console.mdx": "74a676fe64513cb1c76a2fc2c5400c520109efcec352fc354e9b3d64edb4e6a4", - "content/docs/admin-api.mdx": "52bfea0ffc4d1e3bd1b9e476c32250e787e82b1d167c2343e662d7550b6a64e9", - "content/docs/observability.mdx": "c81214e1865517a9163c48c7ce7c396f5490c9d162080dfa05fa5ebc147f6c8b", - "content/docs/troubleshooting.mdx": "cf5b9e9d28fe9b56146bccc8e43d1804b3c6c5cc8ff2bb345aa4a3961329e838", - "content/docs/user-guide.mdx": "92b839c8d1a2fbf4d35388c414c4524734f32724c30c07395edc994fa11cf702", + "content/docs/console.mdx": "8fa26954eeb4d795238df56f1968208e18c06113f7ee42eff1217760dab38c03", + "content/docs/admin-api.mdx": "ea47ada04e91fa65a87d42960467c2ec76548bf496bbd50dde01f8ed71b962f2", + "content/docs/observability.mdx": "d4b37bf5c2b44b55f3e38b71ffadd6e4d590e4891c6ae2f6a03893579b067754", + "content/docs/troubleshooting.mdx": "bc1bc142ccbd7911523c80d21869c0c219d29d3d69c75d6b32232032bd7af635", + "content/docs/user-guide.mdx": "052b394d433bfa42a478e51a3d40ac4a79d5b8e0375fc0fcab0b3ddcd655a11b", "public/images/source/docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", - "content/docs/development.mdx": "777d3a7b7dc03d49cfa094b439d7ab905367951a7150fcc77644edbb1239f552", - "content/docs/harness-onboarding.mdx": "e434d62bd0b558745774c8d729e4d86d2b40ed3026e3d77b777c48aef141c659", - "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", - "content/docs/runtime-protocol.mdx": "560f887d5fc9fa673275a6481220f5fb997c4ece30c09384b81b14dbc7aa0dd4", - "content/docs/sandbox-provider.mdx": "c492be921a1b99c97a73c52d740f061533b174f6ce3023a60feb06087a579564" + "content/docs/development.mdx": "a4be0be579a07330c6ec1bf6ae4b6b08a13bf22fee5ca204f0646ff6b30da626", + "content/docs/harness-onboarding.mdx": "d1950e897ca03cdc411922da0edbf823f805cc897392b3f5d4b6dd3c1a24cb52", + "content/docs/runtime-bootstrap.mdx": "3f7ae2330194c0d639a89737fbf9a43cf33c354694bfa1ac6900e96f84aac2a8", + "content/docs/runtime-protocol.mdx": "7bd6a1a5fb6a49042277c13a866b336ae36e65bbd20eabc5a6bc7cda71ae7b6f", + "content/docs/sandbox-provider.mdx": "7e800f15373fa3e560044d0e1790f417275e5d8d5b9de1b240445606c6c0f960" } } diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 4b6ef85e3..ed336c31a 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -9,7 +9,7 @@ const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) const sourceRevision = 'f6d258735fc601c521dd990e6f9e1ed261f4ef2d' -const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` +const sourceURL = relative => `https://github.com/MiniMax-AI/OpenAgentCore/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { let fence = false diff --git a/apps/docs/scripts/verify-contract-routes.py b/apps/docs/scripts/verify-contract-routes.py index c9f51d2f5..96067c6f6 100644 --- a/apps/docs/scripts/verify-contract-routes.py +++ b/apps/docs/scripts/verify-contract-routes.py @@ -3,7 +3,7 @@ The public contract is constrained by its pinned upstream baseline; Core and machine contracts are generated from handler annotations. This reads the real -`chi` route table from services/agents-api/internal/api and compares all three +`chi` route table from services/core/internal/api and compares all three contracts with the registered paths. Two checks: @@ -28,7 +28,7 @@ APP = pathlib.Path(__file__).resolve().parent.parent REPO = APP.parent.parent -API = REPO / "services/agents-api/internal/api" +API = REPO / "services/core/internal/api" VERBS = ("Get", "Post", "Put", "Patch", "Delete", "Head", "Options", "Trace") diff --git a/apps/parsar-daemon/cmd/parsar-daemon/main.go b/apps/parsar-daemon/cmd/parsar-daemon/main.go deleted file mode 100644 index 53089a684..000000000 --- a/apps/parsar-daemon/cmd/parsar-daemon/main.go +++ /dev/null @@ -1,19 +0,0 @@ -// Command oac-daemon is the reverse-WebSocket worker that pairs a user -// machine with a OpenAgentCore server and exposes a local agent CLI -// subprocess as a connector_type=agent_daemon target. See -// apps/parsar-daemon/README.md for the subcommand spec. -package main - -import ( - "fmt" - "os" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli" -) - -func main() { - if err := cli.Execute(os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "oac-daemon: %v\n", err) - os.Exit(1) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/export_test.go b/apps/parsar-daemon/internal/agent/claudecode/export_test.go deleted file mode 100644 index ed9fa5a20..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/export_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package claudecode - -// This file uses _test.go so it only compiles into the test binary, -// but lives in the production package — re-exports internal symbols -// for the external claudecode_test package without polluting the -// public surface. - -import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - -func NewPendingTableForTest() *PendingTable { return (*PendingTable)(newPendingTable()) } - -// PendingTable is the test-visible alias for pendingTable. -type PendingTable pendingTable - -type PendingEntry = pendingEntry - -func (p *PendingTable) Record(permID, ccRequestID string, input map[string]any) { - (*pendingTable)(p).Record(permID, ccRequestID, input) -} -func (p *PendingTable) Resolve(permID string) (PendingEntry, bool) { - return (*pendingTable)(p).Resolve(permID) -} -func (p *PendingTable) LookupByCC(ccReq string) (string, bool) { - return (*pendingTable)(p).LookupByCC(ccReq) -} -func (p *PendingTable) Delete(permID string) { (*pendingTable)(p).Delete(permID) } -func (p *PendingTable) Len() int { return (*pendingTable)(p).Len() } - -// PendingAskTable is the test-visible alias for pendingAskTable. -type PendingAskTable pendingAskTable - -type PendingAskEntry = pendingAskEntry - -func NewPendingAskTableForTest() *PendingAskTable { - return (*PendingAskTable)(newPendingAskTable()) -} - -func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { - (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) -} -func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { - entry, ok, _ := (*pendingAskTable)(p).Take(askID, proto.PromptForUserChoiceDecisionPayload{Cancelled: true}) - return entry, ok -} -func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { - p.mu.Lock() - defer p.mu.Unlock() - entry, ok := p.byAskID[askID] - return entry, ok -} -func (p *PendingAskTable) Len() int { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.byAskID) -} - -// NewTranslatorForTest constructs a translator with a deterministic -// perm-id minter. askPending and askMint default to nil — covers the -// legacy permission-only callers; pass via NewTranslatorWithAskForTest -// when exercising the AskUserQuestion interception path. -func NewTranslatorForTest(runID string, pending *PendingTable, mint func() string) *Translator { - t := newTranslator(runID, (*pendingTable)(pending), nil, permIDMinter(mint), nil) - return (*Translator)(t) -} - -// NewTranslatorWithAskForTest is the ask-aware variant. -func NewTranslatorWithAskForTest(runID string, pending *PendingTable, askPending *PendingAskTable, mint func() string, askMint func() string) *Translator { - t := newTranslator(runID, (*pendingTable)(pending), (*pendingAskTable)(askPending), permIDMinter(mint), askIDMinter(askMint)) - return (*Translator)(t) -} - -type Translator translator - -type Translation = translation - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -// Re-export proto types for the external test package. -type ( - Envelope = proto.Envelope -) - -// BuildAskUserControlResponseForTest exposes the control_request-path -// writeback builder so ask_test.go can pin the control_response shape -// claude's stdin expects under --permission-prompt-tool stdio. -func BuildAskUserControlResponseForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - return buildAskUserControlResponse(entry, decision) -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/message_input.go b/apps/parsar-daemon/internal/agent/claudecode/message_input.go deleted file mode 100644 index 47a176f29..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/message_input.go +++ /dev/null @@ -1,41 +0,0 @@ -package claudecode - -import ( - "bytes" - "encoding/json" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "strings" -) - -func buildOrderedUserMessages(input proto.MessageInput) ([]byte, error) { - if err := input.Validate(); err != nil { - return nil, err - } - var output bytes.Buffer - encoder := json.NewEncoder(&output) - encoder.SetEscapeHTML(false) - for _, message := range input { - var blocks []userContentBlock - for _, part := range message.Content { - if part.Type == "input_text" { - blocks = append(blocks, userContentBlock{Type: "text", Text: *part.Text}) - continue - } - header, data, ok := strings.Cut(*part.ImageURL, ",") - if !ok || (header != "data:image/png;base64" && header != "data:image/jpeg;base64") { - return nil, fmt.Errorf("claudecode: unsupported image reference") - } - mime := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") - blocks = append(blocks, userContentBlock{Type: "image", Source: &userContentSource{Type: "base64", MediaType: mime, Data: data}}) - } - var content any = blocks - if len(blocks) == 1 && blocks[0].Type == "text" { - content = blocks[0].Text - } - if err := encoder.Encode(userMessage{Type: "user", Message: userMessageContent{Role: "user", Content: content}}); err != nil { - return nil, err - } - } - return output.Bytes(), nil -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/message_input_test.go b/apps/parsar-daemon/internal/agent/claudecode/message_input_test.go deleted file mode 100644 index 249875502..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/message_input_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package claudecode - -import ( - "bytes" - "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "testing" -) - -func TestOrderedMessageInputNativeConversion(t *testing.T) { - image := "data:image/png;base64,aW1hZ2U=" - input := proto.TextInput(" before ") - input[0].Content = append(input[0].Content, proto.InputContent{Type: "input_image", ImageURL: &image}, proto.TextInput(" after ")[0].Content[0]) - input = append(input, proto.TextInput("next")...) - raw, err := buildOrderedUserMessages(input) - if err != nil { - t.Fatal(err) - } - lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n")) - if len(lines) != 2 { - t.Fatalf("message boundary lost: %s", raw) - } - var first struct { - Message struct{ Content []userContentBlock } - } - if err := json.Unmarshal(lines[0], &first); err != nil { - t.Fatal(err) - } - content := first.Message.Content - if len(content) != 3 || content[0].Text != " before " || content[1].Source == nil || content[1].Source.Data != "aW1hZ2U=" || content[2].Text != " after " { - t.Fatalf("native order changed: %s", raw) - } - image = "https://unsupported.example/image.png" - if _, err := buildOrderedUserMessages(input); err == nil { - t.Fatal("unsupported image reference accepted") - } - if _, err := buildOrderedUserMessages(proto.TextInput("")); err == nil { - t.Fatal("empty message accepted") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/options_test.go b/apps/parsar-daemon/internal/agent/claudecode/options_test.go deleted file mode 100644 index 1e17d8bbc..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/options_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "os" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" -) - -func TestBuildArgsBaseHasStreamFlags(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - wantContains := [][2]string{ - {"--output-format", "stream-json"}, - {"--input-format", "stream-json"}, - {"--permission-prompt-tool", "stdio"}, - } - for _, w := range wantContains { - if !containsPair(res.Args, w[0], w[1]) { - t.Errorf("missing flag pair %s=%s in %v", w[0], w[1], res.Args) - } - } - if !slices.Contains(res.Args, "--verbose") { - t.Errorf("missing --verbose in %v", res.Args) - } - if !slices.Contains(res.Args, "--include-partial-messages") { - t.Errorf("missing --include-partial-messages in %v", res.Args) - } -} - -// IS_SANDBOX=1 must be in every env passthrough. Without it Claude -// Code 2.1.x's root-guard kills the subprocess before the first user -// message. -func TestBuildArgsAlwaysExportsIsSandbox(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !slices.Contains(res.Env, "IS_SANDBOX=1") { - t.Errorf("IS_SANDBOX=1 missing from env, got %v", res.Env) - } -} - -// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta fields -// (e.g. context_management.clear_thinking_*) from /v1/messages bodies; -// internal Anthropic-compatible gateways reject unknown fields with 400. -func TestBuildArgsAlwaysDisablesExperimentalBetas(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !slices.Contains(res.Env, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1") { - t.Errorf("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 missing from env, got %v", res.Env) - } -} - -func TestBuildArgsHonoursPrimaryFlags(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "model": "sonnet", - "mode": "acceptEdits", - "allowed_tools": []any{"Bash", "Read", "Write"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--model", "sonnet") { - t.Errorf("--model sonnet not in %v", res.Args) - } - if !containsPair(res.Args, "--permission-mode", "acceptEdits") { - t.Errorf("--permission-mode acceptEdits not in %v", res.Args) - } - if !containsPair(res.Args, "--allowedTools", "Bash,Read,Write") { - t.Errorf("--allowedTools join not in %v", res.Args) - } -} - -func TestBuildArgsResumeUsesExplicitSessionID(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "resume_session_id": "from-map", - }, "from-arg") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--resume", "from-arg") { - t.Errorf("explicit resume id not preferred, args=%v", res.Args) - } - if slices.Contains(res.Args, "from-map") { - t.Errorf("map key leaked into args=%v", res.Args) - } -} - -func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{"resume_session_id": "session_xyz"}, "") - defer res.Cleanup() - if slices.Contains(res.Args, "--resume") || slices.Contains(res.Args, "session_xyz") { - t.Errorf("resume_session_id option must be ignored, args=%v", res.Args) - } -} - -func TestBuildArgsAppendSystemPromptAlone(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{"system_prompt": "be terse"}, "") - defer res.Cleanup() - if !containsPair(res.Args, "--append-system-prompt", "be terse") { - t.Errorf("--append-system-prompt missing, args=%v", res.Args) - } -} - -func TestBuildArgsBypassPermissionsAddsAllowDangerouslyFlag(t *testing.T) { - // Sandbox containers run as root; without - // --allow-dangerously-skip-permissions Claude Code refuses to - // bypass permissions for root callers. - res, err := claudecode.BuildArgs(map[string]any{ - "mode": "bypassPermissions", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--permission-mode", "bypassPermissions") { - t.Errorf("--permission-mode bypassPermissions not in %v", res.Args) - } - if !slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { - t.Errorf("--allow-dangerously-skip-permissions missing for bypass mode, args=%v", res.Args) - } -} - -func TestBuildArgsNonBypassModeOmitsAllowDangerouslyFlag(t *testing.T) { - for _, mode := range []string{"acceptEdits", "default", "plan"} { - res, err := claudecode.BuildArgs(map[string]any{"mode": mode}, "") - if err != nil { - t.Fatalf("BuildArgs(mode=%s): %v", mode, err) - } - defer res.Cleanup() - if slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { - t.Errorf("mode=%s should not enable allow-dangerously flag, args=%v", mode, res.Args) - } - } -} - -func TestBuildArgsOverrideSystemPromptStripAppend(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "system_prompt": "be terse", - "override_system_prompt": "you are pirate", - }, "") - defer res.Cleanup() - if slices.Contains(res.Args, "--append-system-prompt") { - t.Errorf("override should have stripped append, args=%v", res.Args) - } - if !containsPair(res.Args, "--system-prompt", "you are pirate") { - t.Errorf("--system-prompt missing, args=%v", res.Args) - } -} - -func TestBuildArgsPluginDirsRepeated(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "plugin_dirs": []any{"/a", "/b", "/c"}, - }, "") - defer res.Cleanup() - got := 0 - for i, a := range res.Args { - if a == "--plugin-dir" { - got++ - if i+1 >= len(res.Args) { - t.Fatalf("trailing --plugin-dir without value: %v", res.Args) - } - } - } - if got != 3 { - t.Errorf("expected 3 --plugin-dir flags, got %d in %v", got, res.Args) - } -} - -func TestBuildArgsMCPServersWritesTempfile(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "mcp_servers": map[string]any{ - "github": map[string]any{"command": "/usr/local/bin/mcp-github"}, - }, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - // Find the --mcp-config path - path := "" - for i, a := range res.Args { - if a == "--mcp-config" { - if i+1 < len(res.Args) { - path = res.Args[i+1] - } - } - } - if path == "" { - t.Fatalf("--mcp-config path missing, args=%v", res.Args) - } - if !strings.Contains(path, "oac-daemon-mcp-") { - t.Errorf("mcp tempfile name unexpected: %q", path) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat mcp tempfile: %v", err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Errorf("mcp tempfile perm = %o, want 0600", perm) - } - - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read mcp tempfile: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(body, &parsed); err != nil { - t.Fatalf("mcp tempfile not valid json: %v", err) - } - if _, ok := parsed["mcpServers"]; !ok { - t.Errorf("mcp tempfile missing mcpServers wrapper: %s", body) - } - - // Cleanup should remove the file. - res.Cleanup() - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("mcp tempfile still exists after Cleanup: stat err=%v", err) - } -} - -func TestBuildArgsEnvPassthroughIsSorted(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "env": map[string]any{ - "ZED": "1", - "ANTHROPIC_KEY": "secret", - "OTHER_FLAG": "x", - }, - }, "") - defer res.Cleanup() - want := []string{"ANTHROPIC_KEY=secret", "OTHER_FLAG=x", "ZED=1"} - // res.Env always starts with the four baseline values; pop them off - // before checking the sort order of the user-supplied tail. - tail := res.Env[4:] - if !slices.Equal(tail, want) { - t.Errorf("env tail = %v, want sorted %v", tail, want) - } -} - -func TestBuildArgsRejectsWrongShapes(t *testing.T) { - cases := []struct { - name string - opts map[string]any - }{ - {"model not string", map[string]any{"model": 7}}, - {"mode not string", map[string]any{"mode": true}}, - {"allowed_tools not array", map[string]any{"allowed_tools": "Bash"}}, - {"plugin_dirs element not string", map[string]any{"plugin_dirs": []any{"/a", 7}}}, - {"mcp_servers not object", map[string]any{"mcp_servers": "json string"}}, - {"env value not string", map[string]any{"env": map[string]any{"K": 1}}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := claudecode.BuildArgs(tc.opts, "") - if err == nil { - t.Fatal("BuildArgs accepted bad shape") - } - }) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser.go b/apps/parsar-daemon/internal/agent/claudecode/parser.go deleted file mode 100644 index 83bee1778..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/parser.go +++ /dev/null @@ -1,437 +0,0 @@ -package claudecode - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "sync/atomic" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// pendingRecorder is the slice of pendingTable the parser needs. -// Interface form lets parser_test.go substitute a fake. -type pendingRecorder interface { - Record(permID, ccRequestID string, input map[string]any) - LookupByCC(ccRequestID string) (string, bool) -} - -// askRecorder is the slice of pendingAskTable the parser needs. -type askRecorder interface { - RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) -} - -// permIDMinter generates the daemon-side permission id (perm_<8hex>). -// Replaceable in tests so envelope IDs are deterministic. -type permIDMinter func() string - -// askIDMinter generates the daemon-side ask id (ask_<8hex>). -// Replaceable in tests so envelope IDs are deterministic. -type askIDMinter func() string - -func defaultPermIDMinter() string { - var b [4]byte - // crypto/rand.Read failure would silently collide perm ids across - // pending requests, causing an approve-on-wrong-tool bug — panic - // loud so operators see it. - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("claudecode: rand.Read for perm id failed: %v", err)) - } - return "perm_" + hex.EncodeToString(b[:]) -} - -func defaultAskIDMinter() string { - var b [4]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("claudecode: rand.Read for ask id failed: %v", err)) - } - return "ask_" + hex.EncodeToString(b[:]) -} - -// translator converts one NDJSON line from claude stdout into zero or -// more proto.Envelope frames. One translator lives per session. -type translator struct { - runID string - pending pendingRecorder - askPending askRecorder - seq atomic.Uint64 - mint permIDMinter - askMint askIDMinter - partialBlocks map[int]string -} - -func newTranslator(runID string, pending pendingRecorder, askPending askRecorder, mint permIDMinter, askMint askIDMinter) *translator { - if mint == nil { - mint = defaultPermIDMinter - } - if askMint == nil { - askMint = defaultAskIDMinter - } - return &translator{runID: runID, pending: pending, askPending: askPending, mint: mint, askMint: askMint} -} - -// translation is the per-line parser output. -type translation struct { - Envelopes []proto.Envelope - // Terminal is true when this line was a `result` frame — the - // session should stop reading stdout after consuming it. - Terminal bool - // SessionID is the upstream Claude session id surfaced on a system init. - // line (and again on the result frame). session.go writes this - // into binding metadata for --resume. - SessionID string -} - -// rawEnvelope is the minimal shared head every claude stream-json line -// has. -type rawEnvelope struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` -} - -// Translate parses one NDJSON line. Unknown types return an empty -// translation with no error to stay forward-compatible with new claude -// stream variants. Errors are reserved for malformed JSON on frames we -// claim to understand; the session pump treats them as drop-and-log so -// one bad line doesn't kill an otherwise fine run. -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - - var head rawEnvelope - if err := json.Unmarshal(line, &head); err != nil { - return translation{}, fmt.Errorf("claudecode: parse stream-json head: %w", err) - } - - switch head.Type { - case "system": - return t.translateSystem(line) - case "assistant": - return t.translateAssistant(line) - case "stream_event": - return t.translateStreamEvent(line) - case "user": - return t.translateUser(line) - case "control_request": - return t.translateControlRequest(line) - case "control_cancel_request": - return t.translateControlCancel(line) - case "result": - return t.translateResult(line, head.Subtype) - default: - return translation{}, nil - } -} - -// translateStreamEvent handles the raw Anthropic events emitted by Claude -// Code with --include-partial-messages. Claude still emits a complete -// assistant frame after these events, so translateAssistant suppresses its -// text/thinking copies once a corresponding partial delta has been observed. -func (t *translator) translateStreamEvent(line []byte) (translation, error) { - var msg struct { - Event struct { - Type string `json:"type"` - Index int `json:"index"` - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - Thinking string `json:"thinking"` - } `json:"delta"` - } `json:"event"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse stream_event frame: %w", err) - } - if msg.Event.Type != "content_block_delta" { - return translation{}, nil - } - - switch msg.Event.Delta.Type { - case "text_delta": - if msg.Event.Delta.Text == "" { - return translation{}, nil - } - if t.partialBlocks == nil { - t.partialBlocks = make(map[int]string) - } - t.partialBlocks[msg.Event.Index] = "text" - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: msg.Event.Delta.Text, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - case "thinking_delta": - if msg.Event.Delta.Thinking == "" { - return translation{}, nil - } - if t.partialBlocks == nil { - t.partialBlocks = make(map[int]string) - } - t.partialBlocks[msg.Event.Index] = "thinking" - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: msg.Event.Delta.Thinking, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - default: - return translation{}, nil - } -} - -func (t *translator) translateSystem(line []byte) (translation, error) { - var msg struct { - SessionID string `json:"session_id"` - } - // System lines have variable shape (init / compact / etc); missing - // session_id is a no-op, not an error. - _ = json.Unmarshal(line, &msg) - return translation{SessionID: msg.SessionID}, nil -} - -func (t *translator) translateAssistant(line []byte) (translation, error) { - var msg struct { - Message struct { - Content []json.RawMessage `json:"content"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse assistant frame: %w", err) - } - - var envs []proto.Envelope - for index, raw := range msg.Message.Content { - partialIndex := index - // Claude's per-block assistant frames restart content indexes at zero. - if len(msg.Message.Content) == 1 && len(t.partialBlocks) == 1 { - for streamedIndex := range t.partialBlocks { - partialIndex = streamedIndex - } - } - var head struct { - Type string `json:"type"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - switch head.Type { - case "text": - if t.partialBlocks[partialIndex] == "text" { - continue - } - var item struct { - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &item); err != nil || item.Text == "" { - continue - } - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: item.Text, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - case "thinking": - if t.partialBlocks[partialIndex] == "thinking" { - continue - } - var item struct { - Thinking string `json:"thinking"` - } - if err := json.Unmarshal(raw, &item); err != nil || item.Thinking == "" { - continue - } - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: item.Thinking, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - case "tool_use": - var item struct { - ID string `json:"id"` - Name string `json:"name"` - Input map[string]any `json:"input"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - // Note: AskUserQuestion intentionally NOT intercepted on this - // path. claude-code under --permission-prompt-tool stdio (our - // fixed mode) emits the SAME AskUserQuestion call twice — once - // here as a tool_use, then a few ms later as a control_request - // "can_use_tool" check. Intercepting both would produce two - // PromptForUserChoice envelopes / two cards. The control_request - // path is the one we control end-to-end (claude waits for a - // matching control_response), so the tool_use copy goes - // through as a regular TypeToolCall — the UI logs the call, - // the user still only sees one card from the control_request - // path. See translateControlRequest below. - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: item.ID, - Name: item.Name, - Stage: "before", - Args: item.Input, - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - } - } - // Partial flags apply only to the complete assistant frame that follows - // those stream_event deltas. Reset them so a later assistant turn that is - // delivered without partial frames is not accidentally suppressed. - clear(t.partialBlocks) - return translation{Envelopes: envs}, nil -} - -func (t *translator) translateUser(line []byte) (translation, error) { - var msg struct { - Message struct { - Content []json.RawMessage `json:"content"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse user frame: %w", err) - } - - var envs []proto.Envelope - for _, raw := range msg.Message.Content { - var head struct { - Type string `json:"type"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - if head.Type != "tool_result" { - continue - } - var item struct { - ToolUseID string `json:"tool_use_id"` - Content json.RawMessage `json:"content"` - IsError bool `json:"is_error"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: item.ToolUseID, - Stage: "after", - Result: map[string]any{ - "content": decodeToolResultContent(item.Content), - "is_error": item.IsError, - }, - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - } - return translation{Envelopes: envs}, nil -} - -// decodeToolResultContent best-effort decodes claude's tool_result -// content field, declared as `string | ContentBlock[]`. Returned as -// the natural Go shape so downstream consumers don't have to re-parse. -func decodeToolResultContent(raw json.RawMessage) any { - if len(raw) == 0 { - return nil - } - var v any - if err := json.Unmarshal(raw, &v); err != nil { - return string(raw) - } - return v -} - -func (t *translator) translateControlRequest(line []byte) (translation, error) { - var msg struct { - RequestID string `json:"request_id"` - Request struct { - Subtype string `json:"subtype"` - ToolName string `json:"tool_name"` - Input map[string]any `json:"input"` - } `json:"request"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse control_request: %w", err) - } - if msg.RequestID == "" { - return translation{}, fmt.Errorf("claudecode: control_request missing request_id") - } - - // AskUserQuestion comes through here too when claude-code runs with - // --permission-prompt-tool stdio. The SDK wraps it as a "can_use_tool" - // permission check rather than emitting a normal tool_use frame, so - // the tool_use-branch interception in translateAssistant never sees - // it. We re-route it into the ask flow here. The CC request_id is - // stashed under askID inside ccByAsk so SubmitPromptForUserChoice can - // write a matching control_response back. - if msg.Request.ToolName == askUserQuestionToolName { - if askEnv, ok := t.interceptAskUserQuestionFromControlRequest(msg.RequestID, msg.Request.Input); ok { - return translation{Envelopes: []proto.Envelope{askEnv}}, nil - } - // Fall through to the permission path when interception can't - // build a valid payload (e.g. questions array missing). claude - // will at least see SOME response on the control_request channel - // rather than blocking; the user will get a permission card they - // can deny. - } - - permID := t.mint() - if t.pending != nil { - t.pending.Record(permID, msg.RequestID, msg.Request.Input) - } - - title := msg.Request.ToolName - if title == "" { - title = "Permission request" - } - env, err := proto.NewEnvelope(proto.TypePermissionRequest, t.runID, proto.PermissionRequestPayload{ - RequestID: permID, - Tool: msg.Request.ToolName, - Title: title, - Payload: msg.Request.Input, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateControlCancel(line []byte) (translation, error) { - var msg struct { - RequestID string `json:"request_id"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse control_cancel_request: %w", err) - } - if msg.RequestID == "" || t.pending == nil { - return translation{}, nil - } - permID, ok := t.pending.LookupByCC(msg.RequestID) - if !ok { - // Approval already came through and the entry was Delete'd — - // drop silently. - return translation{}, nil - } - env, err := proto.NewEnvelope(proto.TypePermissionCancel, permID, nil) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser_test.go b/apps/parsar-daemon/internal/agent/claudecode/parser_test.go deleted file mode 100644 index 437511419..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/parser_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "fmt" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// counterMinter emits perm_001, perm_002, ... for deterministic -// envelope IDs in tests. -func counterMinter() func() string { - var ( - mu sync.Mutex - n int - ) - return func() string { - mu.Lock() - defer mu.Unlock() - n++ - return fmt.Sprintf("perm_%03d", n) - } -} - -func mustDecode[T any](t *testing.T, raw []byte) T { - t.Helper() - var v T - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("decode %T: %v\nraw=%s", v, err, raw) - } - return v -} - -func TestTranslateSystemInitSurfacesSessionID(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_1", nil, counterMinter()) - line := []byte(`{"type":"system","subtype":"init","session_id":"sess_abc","tools":["Bash"]}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if out.SessionID != "sess_abc" { - t.Errorf("SessionID = %q, want sess_abc", out.SessionID) - } - if len(out.Envelopes) != 0 { - t.Errorf("system init must not emit envelopes, got %d", len(out.Envelopes)) - } - if out.Terminal { - t.Errorf("system init is not terminal") - } -} - -func TestTranslateAssistantTextEmitsDelta(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_42", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"text","text":"hello "}, - {"type":"text","text":"world"} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 2 { - t.Fatalf("want 2 envelopes, got %d", len(out.Envelopes)) - } - for i, env := range out.Envelopes { - if env.Type != "delta" { - t.Errorf("env[%d].Type = %q, want delta", i, env.Type) - } - if env.ID != "run_42" { - t.Errorf("env[%d].ID = %q, want run_42", i, env.ID) - } - } - d1 := mustDecode[struct { - Delta string `json:"delta"` - Sequence uint64 `json:"sequence"` - }](t, out.Envelopes[0].Payload) - d2 := mustDecode[struct { - Delta string `json:"delta"` - Sequence uint64 `json:"sequence"` - }](t, out.Envelopes[1].Payload) - if d1.Delta != "hello " || d2.Delta != "world" { - t.Errorf("delta texts: %q,%q", d1.Delta, d2.Delta) - } - if d1.Sequence == 0 || d2.Sequence <= d1.Sequence { - t.Errorf("sequence not monotonic: %d,%d", d1.Sequence, d2.Sequence) - } -} - -func TestTranslateAssistantThinkingEmitsThinking(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_x", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"thinking","thinking":"let me think...","signature":"sig"} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "thinking" { - t.Fatalf("want one thinking env, got %#v", out.Envelopes) - } - got := mustDecode[struct { - Text string `json:"text"` - }](t, out.Envelopes[0].Payload) - if got.Text != "let me think..." { - t.Errorf("Text = %q", got.Text) - } -} - -func TestTranslatePartialMessagesStreamIncrementallyWithoutAssistantDuplicates(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) - frames := [][]byte{ - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}}`), - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}`), - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"checking"}}}`), - } - - var got []proto.Envelope - for _, frame := range frames { - out, err := tr.Translate(frame) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - got = append(got, out.Envelopes...) - } - if len(got) != 3 { - t.Fatalf("want 3 partial envelopes, got %d", len(got)) - } - if got[0].Type != proto.TypeDelta || got[1].Type != proto.TypeDelta || got[2].Type != proto.TypeThinking { - t.Fatalf("partial envelope types = %q, %q, %q", got[0].Type, got[1].Type, got[2].Type) - } - - full, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello world"},{"type":"thinking","thinking":"checking"}]}}`)) - if err != nil { - t.Fatalf("Translate complete assistant frame: %v", err) - } - if len(full.Envelopes) != 0 { - t.Fatalf("complete assistant frame duplicated partial content: %#v", full.Envelopes) - } - - next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn without partial frames"}]}}`)) - if err != nil { - t.Fatalf("Translate next complete assistant frame: %v", err) - } - if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("next assistant frame was suppressed by stale partial state: %#v", next.Envelopes) - } -} - -func TestTranslateStreamEventIgnoresNonTextDeltas(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 0 { - t.Fatalf("input JSON delta should not emit an envelope: %#v", out.Envelopes) - } -} - -func TestTranslatePartialMessagesSuppressOnlyMatchingContentBlock(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial_blocks", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed"}}}`)) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - - out, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed"},{"type":"text","text":"complete-only"}]}}`)) - if err != nil { - t.Fatalf("Translate complete frame: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("complete-only block should be preserved: %#v", out.Envelopes) - } - got := mustDecode[struct { - Delta string `json:"delta"` - }](t, out.Envelopes[0].Payload) - if got.Delta != "complete-only" { - t.Fatalf("delta = %q, want complete-only", got.Delta) - } -} - -func TestTranslateResultClearsPartialBlocksBeforeNextTurn(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial_error", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial before failure"}}}`)) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - - _, err = tr.Translate([]byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"provider failed"}`)) - if err != nil { - t.Fatalf("Translate error result: %v", err) - } - - next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn"}]}}`)) - if err != nil { - t.Fatalf("Translate next assistant frame: %v", err) - } - if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("next assistant frame was suppressed by result state: %#v", next.Envelopes) - } -} - -func TestTranslateAssistantToolUseEmitsBeforeStage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_t", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"ls"}} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 { - t.Fatalf("want 1 env, got %d", len(out.Envelopes)) - } - got := mustDecode[struct { - ID string `json:"id"` - Name string `json:"name"` - Stage string `json:"stage"` - Args map[string]any `json:"args"` - }](t, out.Envelopes[0].Payload) - if got.ID != "toolu_99" || got.Name != "Bash" || got.Stage != "before" { - t.Errorf("payload mismatch: %+v", got) - } - if got.Args["command"] != "ls" { - t.Errorf("args missing command: %v", got.Args) - } -} - -func TestTranslateAssistantMixedContentMonotonicSequence(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_seq", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"text","text":"a"}, - {"type":"thinking","thinking":"b"}, - {"type":"text","text":"c"}, - {"type":"tool_use","id":"t","name":"Read","input":{}} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 4 { - t.Fatalf("want 4 envs, got %d", len(out.Envelopes)) - } - var seqs []uint64 - for _, e := range out.Envelopes { - if e.Type == "delta" || e.Type == "thinking" { - d := mustDecode[struct { - Sequence uint64 `json:"sequence"` - }](t, e.Payload) - seqs = append(seqs, d.Sequence) - } - } - for i := 1; i < len(seqs); i++ { - if seqs[i] <= seqs[i-1] { - t.Errorf("sequence not strictly increasing: %v", seqs) - } - } -} - -func TestTranslateUserToolResultEmitsAfterStage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) - line := []byte(`{"type":"user","message":{"role":"user","content":[ - {"type":"tool_result","tool_use_id":"toolu_99","content":"hello\nworld\n","is_error":false} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "tool_call" { - t.Fatalf("want one tool_call after env, got %#v", out.Envelopes) - } - got := mustDecode[struct { - ID string `json:"id"` - Stage string `json:"stage"` - Result map[string]any `json:"result"` - }](t, out.Envelopes[0].Payload) - if got.ID != "toolu_99" || got.Stage != "after" { - t.Errorf("got %+v", got) - } - if got.Result["content"] != "hello\nworld\n" { - t.Errorf("content not preserved, got %v", got.Result["content"]) - } - if got.Result["is_error"] != false { - t.Errorf("is_error not preserved: %v", got.Result["is_error"]) - } -} - -func TestTranslateUserToolResultPreservesStructuredContent(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) - line := []byte(`{"type":"user","message":{"role":"user","content":[ - {"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":"x"}],"is_error":true} - ]}}`) - out, _ := tr.Translate(line) - got := mustDecode[struct { - Result map[string]any `json:"result"` - }](t, out.Envelopes[0].Payload) - if _, ok := got.Result["content"].([]any); !ok { - t.Errorf("expected []any content block array, got %T %v", got.Result["content"], got.Result["content"]) - } -} - -func TestTranslateControlRequestMintsPermIDAndRecords(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - line := []byte(`{"type":"control_request","request_id":"req_001","request":{ - "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/a"} - }}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_request" { - t.Fatalf("want one permission_request env, got %#v", out.Envelopes) - } - env := out.Envelopes[0] - if env.ID != "run_z" { - t.Errorf("env.ID = %q, want run_z", env.ID) - } - pr := mustDecode[struct { - RequestID string `json:"request_id"` - Tool string `json:"tool"` - Title string `json:"title"` - Payload map[string]any `json:"payload"` - }](t, env.Payload) - if pr.RequestID != "perm_001" || pr.Tool != "Bash" || pr.Title != "Bash" { - t.Errorf("permission payload mismatch: %+v", pr) - } - if pr.Payload["command"] != "rm -rf /tmp/a" { - t.Errorf("payload not preserved: %v", pr.Payload) - } - entry, ok := pending.Resolve("perm_001") - if !ok || entry.CCRequestID != "req_001" { - t.Errorf("pending table not recorded: %+v ok=%v", entry, ok) - } -} - -func TestTranslateControlCancelLooksUpPerm(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - // Seed by translating the original control_request. - _, _ = tr.Translate([]byte(`{"type":"control_request","request_id":"req_5","request":{"subtype":"can_use_tool","tool_name":"Write","input":{}}}`)) - out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_5"}`)) - if err != nil { - t.Fatalf("Translate cancel: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_cancel" { - t.Fatalf("want one permission_cancel env, got %#v", out.Envelopes) - } - if out.Envelopes[0].ID != "perm_001" { - t.Errorf("cancel env.ID = %q, want perm_001", out.Envelopes[0].ID) - } -} - -func TestTranslateControlCancelUnknownCCIDDropped(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_nope"}`)) - if err != nil { - t.Fatalf("Translate cancel: %v", err) - } - if len(out.Envelopes) != 0 { - t.Errorf("unknown cancel should be dropped, got %#v", out.Envelopes) - } -} - -func TestTranslateUnknownTypeIsNoOp(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"some_future_thing","foo":1}`)) - if err != nil { - t.Fatalf("unknown type should not error: %v", err) - } - if len(out.Envelopes) != 0 || out.Terminal { - t.Errorf("unknown type emitted envelopes/terminal: %#v term=%v", out.Envelopes, out.Terminal) - } -} - -func TestTranslateBlankLineIsNoOp(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - for _, s := range []string{"", " ", "\n", "\t \n"} { - out, err := tr.Translate([]byte(s)) - if err != nil { - t.Errorf("blank line %q errored: %v", s, err) - } - if len(out.Envelopes) != 0 { - t.Errorf("blank line %q emitted envs", s) - } - } -} - -func TestTranslateMalformedJSONReturnsError(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"assistant", broken`)) - if err == nil { - t.Error("expected error on malformed JSON") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session.go b/apps/parsar-daemon/internal/agent/claudecode/session.go deleted file mode 100644 index 7eac59cf4..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/session.go +++ /dev/null @@ -1,705 +0,0 @@ -package claudecode - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -// sessionConfig customises Factory for tests (alternative binary path, -// alternative logger, shorter SIGTERM→SIGKILL escalation). -type sessionConfig struct { - // claudeBinary defaults to binpath.ClaudeCode(): the bare name - // "claude" for a PATH lookup, or the OAC_RUNTIME_CLAUDE_BIN override. - claudeBinary string - - // extraArgs are appended after BuildArgs' output. Tests use this - // for the os/exec helper-process pattern. - extraArgs []string - - // killTimeout is how long Cancel waits for SIGTERM to drain - // before SIGKILL. 3s in production; tests pin it short. - killTimeout time.Duration - - // askTimeout bounds how long the daemon waits for the human to answer a - // permission or prompt_for_user_choice (AskUserQuestion). 10 minutes in - // production; tests pin it short so timeout paths are exercisable. - // Zero disables the timer — leftover scaffolding for very early - // adapter wiring; production always picks defaultAskTimeout. - askTimeout time.Duration - - logger *slog.Logger -} - -const defaultAskTimeout = 10 * time.Minute - -func defaultConfig() sessionConfig { - return sessionConfig{ - claudeBinary: binpath.ClaudeCode(), - killTimeout: 3 * time.Second, - askTimeout: defaultAskTimeout, - logger: obslog.Bg(), - } -} - -// Factory implements agent.Factory for agent_kind="claude_code". -// Register during daemon startup: -// -// Register the factory with an explicit capability descriptor using Registry.RegisterKind. -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `claude` CLI subprocess. -type Session struct { - runID string - cfg sessionConfig - - proc *clirunner.Process - stdin io.WriteCloser - stdinMu sync.Mutex - - pending *pendingTable - askPending *pendingAskTable - translator *translator - - out chan<- proto.Envelope - closeOutOnce sync.Once - outMu sync.RWMutex - outClosed bool - - // cancelCtx is a child of parent ctx so Session.Cancel can signal - // everyone without racing router shutdown. - cancelCtx context.Context - - cancelOnce sync.Once - - // interactionTimersMu guards permission and AskUserQuestion watchdogs. - // Timeout callbacks and human responses race through atomic pending - // tables, so only one response can reach Claude Code. - interactionTimersMu sync.Mutex - interactionTimers map[string]*time.Timer - - // latestSessionID is the most recent upstream session id seen on a - // system-init / result frame. The cancel-path done envelope reads - // it back so the server can RememberSession even when claude was - // killed mid-prompt (without it, the next user message starts a - // brand-new chat with no --resume). - latestSessionIDMu sync.Mutex - latestSessionID string - - buildCleanup func() -} - -var _ agent.Session = (*Session)(nil) - -// newSession is the internal constructor; cfg lets tests inject a fake -// claude binary and a short kill timeout. -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("claudecode: nil out channel") - } - if err := req.Input.Validate(); err != nil { - return nil, err - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.claudeBinary == "" { - cfg.claudeBinary = binpath.ClaudeCode() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - cfg.logger.Info("claudecode: newSession start", - "run_id", req.RunID, "agent_kind", req.AgentKind, - "prompt_len", len(req.Input), "work_dir", req.WorkDir, - "has_agent_options", req.AgentOptions != nil, - "agent_session_id", req.AgentSessionID, - "claude_binary", cfg.claudeBinary) - - // Install plugins BEFORE BuildArgs so the resolved local paths - // can be folded into opts["plugin_dirs"]. installPlugins demotes - // individual plugins to warnings; a hard error (e.g. mkdir fail) - // aborts the session. - // - // sessionWorkDir is reused for cmd.Dir below so plugins land at - // /.claude/plugins/ and the claude subprocess sees - // them at cwd-relative paths. - sessionWorkDir, err := resolveSessionWorkDir(req.WorkDir, req.ConversationID) - if err != nil { - cfg.logger.Error("claudecode: resolveSessionWorkDir failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: resolve session workDir: %w", err) - } - if sessionWorkDir != req.WorkDir { - cfg.logger.Info("claudecode: req.WorkDir empty, using resolved session dir", - "run_id", req.RunID, "session_dir", sessionWorkDir) - } - - pluginOpts := req.AgentOptions - if rawPlugins, ok := pluginOpts["plugins"]; ok { - descriptors, decodeWarns := decodePluginDescriptors(rawPlugins) - for _, w := range decodeWarns { - cfg.logger.Warn("claudecode: plugin descriptor decode warning", - "run_id", req.RunID, "msg", w) - } - installRes, err := installPlugins(parent, cfg.logger, sessionWorkDir, descriptors) - if err != nil { - cfg.logger.Error("claudecode: installPlugins failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: install plugins: %w", err) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("claudecode: plugin install warning", - "run_id", req.RunID, "msg", w) - } - if len(installRes.PluginDirs) > 0 { - // Defensive copy so we never mutate the caller's map. - // Existing plugin_dirs (hand-configured override) wins; - // capability-resolved dirs append. - pluginOpts = cloneAgentOptions(req.AgentOptions) - pluginOpts["plugin_dirs"] = mergePluginDirs(pluginOpts["plugin_dirs"], installRes.PluginDirs) - } - cfg.logger.Info("claudecode: plugins installed", - "run_id", req.RunID, - "plugin_count", len(descriptors), - "dir_count", len(installRes.PluginDirs)) - } - - // Skills install to /.claude/skills//, which - // Claude Code auto-scans at startup. No CLI flag, no opts mutation. - if rawSkills, ok := pluginOpts["skills"]; ok { - descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) - for _, w := range decodeWarns { - cfg.logger.Warn("claudecode: skill descriptor decode warning", - "run_id", req.RunID, "msg", w) - } - installRes, err := installSkills(parent, cfg.logger, sessionWorkDir, descriptors) - if err != nil { - cfg.logger.Error("claudecode: installSkills failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: install skills: %w", err) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("claudecode: skill install warning", - "run_id", req.RunID, "msg", w) - } - cfg.logger.Info("claudecode: skills installed", - "run_id", req.RunID, - "skill_count", len(descriptors), - "warn_count", len(installRes.Warnings)) - } - - buildRes, err := BuildArgs(pluginOpts, req.AgentSessionID) - if err != nil { - cfg.logger.Error("claudecode: BuildArgs failed", "run_id", req.RunID, "err", err) - return nil, fmt.Errorf("claudecode: build args: %w", err) - } - cfg.logger.Info("claudecode: BuildArgs ok", - "run_id", req.RunID, "arg_count", len(buildRes.Args), "env_count", len(buildRes.Env)) - - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - - cfg.logger.Info("claudecode: starting subprocess", - "run_id", req.RunID, "binary", cfg.claudeBinary, - "arg_count", len(args), "dir", sessionWorkDir) - proc, err := clirunner.Start(clirunner.StartOptions{ - Parent: parent, - Binary: cfg.claudeBinary, - Args: args, - Dir: sessionWorkDir, - Env: append(os.Environ(), buildRes.Env...), - NeedStdin: true, - KillTimeout: cfg.killTimeout, - }) - if err != nil { - cfg.logger.Error("claudecode: cmd.Start failed", - "run_id", req.RunID, "binary", cfg.claudeBinary, "err", err) - buildRes.Cleanup() - return nil, fmt.Errorf("claudecode: start %q: %w", cfg.claudeBinary, err) - } - cfg.logger.Info("claudecode: subprocess started", - "run_id", req.RunID, "pid", proc.Cmd.Process.Pid) - - pending := newPendingTable() - askPending := newPendingAskTable() - s := &Session{ - runID: req.RunID, - cfg: cfg, - proc: proc, - stdin: proc.Stdin, - pending: pending, - askPending: askPending, - translator: newTranslator(req.RunID, pending, askPending, defaultPermIDMinter, defaultAskIDMinter), - out: out, - cancelCtx: proc.Context(), - interactionTimers: make(map[string]*time.Timer), - buildCleanup: buildRes.Cleanup, - } - - // Write the initial user message before launching pumps so the - // first stdout line corresponds to the prompt we just sent. Best - // effort: write failure → pump sees EOF and synthesises - // error+done. - if msg, err := buildOrderedUserMessages(req.Input); err == nil { - cfg.logger.Info("claudecode: writing initial user message to stdin", - "run_id", req.RunID, "msg_bytes", len(msg)) - if _, werr := s.writeStdin(msg); werr != nil { - cfg.logger.Warn("claudecode: write initial user message", - "run_id", req.RunID, "err", werr) - } else { - cfg.logger.Info("claudecode: initial user message written ok", "run_id", req.RunID) - } - } else { - cfg.logger.Warn("claudecode: build user message", - "run_id", req.RunID, "err", err) - } - - cfg.logger.Info("claudecode: launching stdout/stderr pumps", "run_id", req.RunID) - go s.pumpStderr(proc.Stderr) - go s.run(proc.Stdout) - - return s, nil -} - -// writeStdin appends to claude's stdin under a mutex (claude only -// promises NDJSON framing; concurrent writes from SubmitPermission and -// the initial user-message write must not tear). -func (s *Session) writeStdin(b []byte) (int, error) { - s.stdinMu.Lock() - defer s.stdinMu.Unlock() - return s.stdin.Write(b) -} - -// Cancel asks the subprocess to stop. SIGTERM, escalating to SIGKILL -// after cfg.killTimeout. Idempotent; the actual teardown (out chan -// close) happens asynchronously via the pump. -func (s *Session) Cancel(_ context.Context) error { - s.cancelOnce.Do(func() { - s.stopAllInteractionTimers() - s.proc.Cancel() - }) - return nil -} - -// stopAllInteractionTimers cancels every outstanding human-response -// watchdog. Called on session Cancel/terminal so timers cannot fire into a -// closed stdin. -func (s *Session) stopAllInteractionTimers() { - s.interactionTimersMu.Lock() - timers := s.interactionTimers - s.interactionTimers = make(map[string]*time.Timer) - s.interactionTimersMu.Unlock() - for _, t := range timers { - t.Stop() - } -} - -// SubmitPermission writes a control_response back to claude for the -// given perm_id. Returns agent.ErrUnknownPermission when permID isn't -// in the pending table. -func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { - entry, ok := s.pending.Take(permID) - if !ok { - return agent.ErrUnknownPermission - } - s.stopInteractionTimer(permID) - - var inner map[string]any - if decision.Approved { - updatedInput := decision.UpdatedInput - if updatedInput == nil { - updatedInput = entry.Input - } - inner = map[string]any{ - "behavior": "allow", - "updatedInput": updatedInput, - } - } else { - msg := decision.Message - if msg == "" { - msg = "denied by operator" - } - inner = map[string]any{ - "behavior": "deny", - "message": msg, - } - } - - body, err := json.Marshal(map[string]any{ - "type": "control_response", - "response": map[string]any{ - "subtype": "success", - "request_id": entry.CCRequestID, - "response": inner, - }, - }) - if err != nil { - return fmt.Errorf("claudecode: marshal control_response: %w", err) - } - body = append(body, '\n') - - if _, err := s.writeStdin(body); err != nil { - // Restore the entry so a transient stdin write failure remains - // retryable and still has a bounded lifetime. - s.pending.Record(permID, entry.CCRequestID, entry.Input) - s.startPermissionTimer(permID) - return fmt.Errorf("claudecode: write control_response: %w", err) - } - return nil -} - -// SubmitPromptForUserChoice writes a tool_result back into claude's -// stdin for the AskUserQuestion call the daemon intercepted. Returns -// agent.ErrUnknownAsk when askID isn't in the pending ask table — -// usually a race with Cancel or a duplicate decision from the server. -// -// The reply is shaped as a normal Claude Code tool_result message; the -// model resumes its turn as if the local SDK had executed the tool -// and supplied the human's answer. -// -// Cancelled answers (timeout, operator /cancel) still write back -// is_error=false text — see plan: returning is_error=true would push -// the agent into a "retry the same tool" loop, which is worse UX than -// telling it "the user stopped, fold and report". -func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - // Take is atomic read+delete; the timer-fired cancel and a server- - // delivered answer can both reach here, but only one wins. The - // loser sees ok=false and returns ErrUnknownAsk — the router logs - // and moves on. - entry, ok, err := s.askPending.Take(askID, decision) - if err != nil { - return err - } - if !ok { - return agent.ErrUnknownAsk - } - - // Drop the timer so its callback (if pending) finds the entry gone - // and returns silently. Already-fired callbacks lost the Take race - // above; stop is best-effort either way. - s.stopAskTimer(askID) - - body, buildEr := buildAskUserControlResponse(entry, decision) - if buildEr != nil { - return fmt.Errorf("claudecode: marshal ask reply: %w", buildEr) - } - if _, err := s.writeStdin(body); err != nil { - // Entry is already gone (Take consumed it). A retry will see - // ErrUnknownAsk; the session is going to die anyway when stdin - // errors, so we don't try to restore the entry. - return fmt.Errorf("claudecode: write ask reply: %w", err) - } - return nil -} - -// startAskTimer launches a single-shot watchdog that times the human -// out after cfg.askTimeout. On fire, the watchdog submits a Cancelled -// decision against itself so the agent's tool_result lands the same -// way as if the operator had clicked "stop" — no special "timeout" -// branch in SubmitPromptForUserChoice. -func (s *Session) startAskTimer(askID string) { - if s.cfg.askTimeout <= 0 { - return - } - timer := time.AfterFunc(s.cfg.askTimeout, func() { - _ = s.SubmitPromptForUserChoice(context.Background(), askID, proto.PromptForUserChoiceDecisionPayload{ - Cancelled: true, - Reason: "timeout", - }) - }) - s.interactionTimersMu.Lock() - if prev, ok := s.interactionTimers[askID]; ok { - // Same askID seen twice: cancel the old timer so we don't fire - // two decisions. Shouldn't happen on a clean stream, but two - // stdout-pump dispatches with the same env.ID would otherwise - // race. - prev.Stop() - } - s.interactionTimers[askID] = timer - s.interactionTimersMu.Unlock() -} - -func (s *Session) stopAskTimer(askID string) { - s.stopInteractionTimer(askID) -} - -// startPermissionTimer applies the same bounded human-response window to -// tool approvals. Expiry is an explicit deny, never an implicit allow. -func (s *Session) startPermissionTimer(permID string) { - if s.cfg.askTimeout <= 0 || permID == "" { - return - } - timer := time.AfterFunc(s.cfg.askTimeout, func() { - _ = s.SubmitPermission(context.Background(), permID, proto.PermissionDecisionPayload{ - Approved: false, - Message: "permission request timed out", - }) - }) - s.interactionTimersMu.Lock() - if prev, ok := s.interactionTimers[permID]; ok { - prev.Stop() - } - s.interactionTimers[permID] = timer - s.interactionTimersMu.Unlock() -} - -func (s *Session) stopInteractionTimer(id string) { - s.interactionTimersMu.Lock() - timer, ok := s.interactionTimers[id] - if ok { - delete(s.interactionTimers, id) - } - s.interactionTimersMu.Unlock() - if ok { - timer.Stop() - } -} - -// run is the stdout pump. Owns the out channel close. -func (s *Session) run(stdout io.Reader) { - s.cfg.logger.Info("claudecode: run() stdout pump started", "run_id", s.runID) - defer s.buildCleanup() - defer s.closeOut() - - sc := bufio.NewScanner(stdout) - // Claude can emit very large tool_result lines in one frame; 16MB - // is far above any practical single-tool output. - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - - lineCount := 0 - terminal := false - for sc.Scan() { - line := sc.Bytes() - lineCount++ - s.cfg.logger.Info("claudecode: stdout line", - "run_id", s.runID, "line_num", lineCount, "len", len(line), - "head", string(line[:min(len(line), 200)])) - tx, err := s.translator.Translate(line) - if err != nil { - s.cfg.logger.Warn("claudecode: translate line", - "run_id", s.runID, "err", err, "len", len(line)) - continue - } - if id := strings.TrimSpace(tx.SessionID); id != "" { - s.latestSessionIDMu.Lock() - s.latestSessionID = id - s.latestSessionIDMu.Unlock() - } - s.cfg.logger.Info("claudecode: translated", - "run_id", s.runID, "line_num", lineCount, - "envelope_count", len(tx.Envelopes), "terminal", tx.Terminal) - for _, env := range tx.Envelopes { - select { - case s.out <- env: - s.cfg.logger.Info("claudecode: envelope sent to out", - "run_id", s.runID, "type", env.Type, "env_id", env.ID) - if env.Type == proto.TypePromptForUserChoice { - // Start the human-answer watchdog AFTER the envelope - // has been handed off — counting the 10-minute window - // from "router has it" not "we're about to try". A - // slow consumer that blocked us on the send shouldn't - // also burn timeout budget the human never saw. - // - // Late-answer race: if the router somehow delivers a - // decision before we finish startAskTimer, the Take - // inside SubmitPromptForUserChoice still wins - // exclusively; the timer just becomes a no-op when it - // fires. - // - // Ask id lives on the payload now (env.ID is the run - // id so server-side dispatch can fan to the run's - // subscriber); decode just enough to seed the timer. - var p proto.PromptForUserChoicePayload - if err := env.DecodePayload(&p); err == nil && p.AskID != "" { - s.startAskTimer(p.AskID) - } - } else if env.Type == proto.TypePermissionRequest { - var p proto.PermissionRequestPayload - requestID := "" - if err := env.DecodePayload(&p); err == nil { - requestID = strings.TrimSpace(p.RequestID) - } - if requestID == "" { - requestID = strings.TrimSpace(env.ID) - } - if requestID != "" { - s.startPermissionTimer(requestID) - } - } - case <-s.cancelCtx.Done(): - s.cfg.logger.Info("claudecode: cancelled during out send", "run_id", s.runID) - _ = s.proc.Wait() - return - } - } - if tx.Terminal { - terminal = true - s.stopAllInteractionTimers() - s.closeOut() - break - } - } - if err := sc.Err(); err != nil && - !errors.Is(err, io.EOF) && - !errors.Is(err, context.Canceled) { - s.cfg.logger.Warn("claudecode: scan stdout", - "run_id", s.runID, "err", err) - } - s.cfg.logger.Info("claudecode: stdout pump exiting", - "run_id", s.runID, "lines_read", lineCount, "terminal", terminal) - - waitErr := s.proc.Wait() - s.cfg.logger.Info("claudecode: subprocess exited", - "run_id", s.runID, "wait_err", waitErr, - "exit_code", s.proc.Cmd.ProcessState.ExitCode()) - - if !terminal { - s.synthesizeTerminal(waitErr) - } -} - -// pumpStderr drains and logs stderr so the subprocess doesn't block -// on a full pipe. -func (s *Session) pumpStderr(stderr io.Reader) { - s.cfg.logger.Info("claudecode: pumpStderr started", "run_id", s.runID) - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - lineCount := 0 - for sc.Scan() { - lineCount++ - s.cfg.logger.Warn("claude stderr", - "run_id", s.runID, "line", sc.Text()) - } - s.cfg.logger.Info("claudecode: pumpStderr done", "run_id", s.runID, "lines", lineCount) -} - -// synthesizeTerminal emits error+done when the subprocess exited -// without a result frame. -func (s *Session) synthesizeTerminal(waitErr error) { - msg := "claude_code: subprocess exited without result" - if waitErr != nil { - msg = fmt.Sprintf("claude_code: subprocess exited: %v", waitErr) - } - if s.cancelCtx.Err() != nil { - msg = "claude_code: cancelled" - } - if errEnv, err := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: msg}); err == nil { - s.trySend(errEnv) - } - if doneEnv, err := proto.NewEnvelope(proto.TypeDone, s.runID, proto.DonePayload{Metadata: s.doneMetaForCancel()}); err == nil { - s.trySend(doneEnv) - } -} - -// doneMetaForCancel returns the metadata map attached to the cancel-path Done envelope. -func (s *Session) doneMetaForCancel() map[string]any { - s.latestSessionIDMu.Lock() - id := s.latestSessionID - s.latestSessionIDMu.Unlock() - if id == "" { - return nil - } - return map[string]any{ - proto.DoneMetaAgentSessionID: id, - proto.DoneMetaAgentSessionType: "claude_session", - } -} - -func (s *Session) trySend(env proto.Envelope) { - s.outMu.RLock() - defer s.outMu.RUnlock() - if s.outClosed { - return - } - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("claudecode: terminal send timed out", - "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { - s.closeOutOnce.Do(func() { - s.outMu.Lock() - s.outClosed = true - close(s.out) - s.outMu.Unlock() - }) -} - -// resolveSessionWorkDir returns the directory that BOTH plugin installs -// AND the claude_code subprocess cwd share for this run. Keeping them -// on the same tree prevents the bug where the subprocess ran in one -// place (sandbox image WORKDIR) while plugins sat under ~/.oac/ -// — `--plugin-dir` still worked but the agent's own `ls .claude/ -// plugins/` self-check answered "no plugins here". -// -// Resolution order: -// -// 1. req.WorkDir wins. Local mode where the operator pinned a project -// root. Must be absolute or start with ~/ (we reject relative paths -// instead of resolving them against daemon cwd, since the daemon's cwd is -// not a meaningful anchor for user-facing config) and we mkdir -p -// so the user can name a path that doesn't exist yet. -// 2. conversationID present → per-conversation scratch dir under -// daemon HOME (~/.oac/runtime/claudecode/conv-). -// Consecutive turns reuse the same .cache-key files. Sandbox-mode -// default, also the local fallback when work_dir is unbound. -// 3. Both empty → daemon's own cwd (os.Getwd). Backstop matching -// pre-plugin behavior. -// -// Errors propagate so the eventual "could not extract zip" gets a -// clearer message. -func resolveSessionWorkDir(workDir, conversationID string) (string, error) { - if trimmed := strings.TrimSpace(workDir); trimmed != "" { - if strings.HasPrefix(trimmed, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home dir: %w", err) - } - trimmed = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - } else if !filepath.IsAbs(trimmed) { - return "", fmt.Errorf("work_dir must be an absolute path, got %q", trimmed) - } - if err := os.MkdirAll(trimmed, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", trimmed, err) - } - return trimmed, nil - } - if convID := strings.TrimSpace(conversationID); convID != "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("os.UserHomeDir: %w", err) - } - dir := filepath.Join(home, ".oac", "runtime", "claudecode", "conv-"+convID) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", dir, err) - } - return dir, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("os.Getwd: %w", err) - } - return cwd, nil -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go deleted file mode 100644 index 94d1ce9f8..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/session_knowledge_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package claudecode - -import ( - "bytes" - "log/slog" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestStartupLogsDoNotContainReferenceDocuments(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - var output bytes.Buffer - const privateDocument = "PRIVATE-REFERENCE-9481" - _, err := newSession(t.Context(), proto.PromptRequestPayload{ - RunID: "knowledge-log-check", WorkDir: t.TempDir(), Input: proto.TextInput("Answer from the reference."), - AgentOptions: map[string]any{"system_prompt": privateDocument}, - }, make(chan proto.Envelope, 8), sessionConfig{ - claudeBinary: filepath.Join(t.TempDir(), "missing-claude"), - logger: slog.New(slog.NewTextHandler(&output, nil)), - }) - if err == nil || !strings.Contains(output.String(), "starting subprocess") { - t.Fatalf("did not exercise subprocess startup: %v", err) - } - if strings.Contains(output.String(), privateDocument) { - t.Fatal("reference documents leaked into startup logs") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_test.go deleted file mode 100644 index b848a1dc2..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/session_test.go +++ /dev/null @@ -1,622 +0,0 @@ -package claudecode_test - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "os" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `claude` when -// CLAUDECODE_TESTHELPER_ROLE is set, bypassing m.Run so the test -// framework's PASS line never pollutes the fake stdout. -const helperEnvKey = "CLAUDECODE_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(helperEnvKey); role != "" { - runFakeClaude(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -// runFakeClaude pretends to be the `claude` CLI in stream-json mode. -func runFakeClaude(role string) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - stdin := bufio.NewScanner(os.Stdin) - stdin.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - - // Wait for the daemon's initial user message so stream-json frame - // ordering is deterministic. - _ = stdin.Scan() - - switch role { - case "echo-success": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_echo", - }) - _ = enc.Encode(map[string]any{ - "type": "assistant", - "message": map[string]any{ - "role": "assistant", - "content": []map[string]any{ - {"type": "text", "text": "hi there"}, - }, - }, - }) - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "hi there", - "session_id": "sess_echo", - "usage": map[string]int{"input_tokens": 5, "output_tokens": 2}, - }) - - case "terminal-wait": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_terminal_wait", - }) - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "background work started", - "session_id": "sess_terminal_wait", - }) - for stdin.Scan() { - } - - case "echo-error": - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "error_during_execution", - "is_error": true, "error": "boom from fake", - }) - - case "permission": - _ = enc.Encode(map[string]any{ - "type": "control_request", "request_id": "req_cc_42", - "request": map[string]any{ - "subtype": "can_use_tool", "tool_name": "Bash", - "input": map[string]any{"command": "ls"}, - }, - }) - // Wait for the daemon's control_response. - approved := false - ccID := "" - if stdin.Scan() { - var decision struct { - Type string `json:"type"` - Response struct { - RequestID string `json:"request_id"` - Response struct { - Behavior string `json:"behavior"` - } `json:"response"` - } `json:"response"` - } - if err := json.Unmarshal(stdin.Bytes(), &decision); err == nil { - approved = decision.Response.Response.Behavior == "allow" - ccID = decision.Response.RequestID - } - } - text := "denied for " + ccID - if approved { - text = "allowed for " + ccID - } - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": text, - }) - - case "hang": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_hang", - }) - // Long sleep keeps a runtime timer alive so Go's deadlock - // detector doesn't panic to stderr. SIGTERM still kills it. - time.Sleep(10 * time.Minute) - - case "ask-question": - // Stream an AskUserQuestion as a control_request (the path - // claude-code takes under --permission-prompt-tool stdio). - // Block on stdin waiting for the daemon's control_response - // carrying the human's answer; echo it back via the final - // result frame so the test can assert the round-trip text. - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_ask", - }) - _ = enc.Encode(map[string]any{ - "type": "control_request", - "request_id": "cc_req_ask_1", - "request": map[string]any{ - "subtype": "can_use_tool", - "tool_name": "AskUserQuestion", - "input": map[string]any{ - "questions": []map[string]any{{ - "header": "Confirm delete", - "question": "Delete /tmp directory?", - "multiSelect": false, - "options": []map[string]any{ - {"label": "Confirm delete", "description": "Run rm -rf"}, - {"label": "Cancel", "description": "Do not run"}, - }, - }}, - }, - }, - }) - - // Wait for the daemon's control_response. Body shape: - // {type:"control_response", response:{subtype:"success", - // request_id:"...", response:{behavior:"deny", message:"..."}}} - answerText := "" - if stdin.Scan() { - var cr struct { - Type string `json:"type"` - Response struct { - Subtype string `json:"subtype"` - Response struct { - Behavior string `json:"behavior"` - Message string `json:"message"` - } `json:"response"` - } `json:"response"` - } - if err := json.Unmarshal(stdin.Bytes(), &cr); err == nil { - answerText = cr.Response.Response.Message - } - } - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "echoed:" + answerText, - "session_id": "sess_ask", - }) - } -} - -func helperConfig() claudecode.SessionConfigForTest { - return claudecode.SessionConfigForTest{ - ClaudeBinary: os.Args[0], - // belt-and-braces: -test.run=^$ stops any tests from running - // if TestMain forgets to short-circuit. - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -// helperReq points the helper at a specific role via env passthrough. -func helperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - helperEnvKey: role, - }, - }, - } -} - -// drain reads envelopes until out closes or dl fires. Second return -// is true on a clean close, false on timeout. -func drain(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionEndToEndSuccess(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_s", "hello", "echo-success"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - if len(got) == 0 { - t.Fatal("got no envelopes") - } - if got[len(got)-1].Type != "done" { - t.Errorf("last env type = %q, want done", got[len(got)-1].Type) - } - - types := envTypes(got) - mustContain(t, types, "delta") - mustContain(t, types, "usage") - mustContain(t, types, "done") - for _, e := range got { - if e.ID != "run_s" { - t.Errorf("env type=%s ID=%q, want run_s", e.Type, e.ID) - } - } -} - -func TestTerminalResultKeepsProcessAliveUntilCancel(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_terminal_wait", "start background work", "terminal-wait"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envelopes", len(got)) - } - mustContain(t, envTypes(got), proto.TypeDone) - - select { - case <-sess.ProcessDoneForTest(): - t.Fatal("terminal result killed the CLI process before the idle timeout") - case <-time.After(50 * time.Millisecond): - } - - if err := sess.Cancel(context.Background()); err != nil { - t.Fatalf("Cancel: %v", err) - } - select { - case <-sess.ProcessDoneForTest(): - case <-time.After(2 * time.Second): - t.Fatal("CLI process did not exit after explicit cancel") - } -} - -func TestSessionEndToEndError(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_e", "hello", "echo-error"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := envTypes(got) - mustContain(t, types, "error") - mustContain(t, types, "done") - if got[len(got)-1].Type != "done" { - t.Errorf("last env not done: %s", got[len(got)-1].Type) - } -} - -func TestSessionCancelClosesOut(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_c", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - // Give the helper a moment to emit the system init line. - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := envTypes(got) - // Synthesised cancel terminal: error + done. - mustContain(t, types, "done") -} - -func TestSessionCancelIsIdempotent(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_c2", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - _ = sess.Cancel(context.Background()) - _ = sess.Cancel(context.Background()) - _ = sess.Cancel(context.Background()) - _, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after redundant Cancels") - } -} - -func TestSessionSubmitPermissionUnknownReturnsErrUnknown(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_p0", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - err = sess.SubmitPermission(context.Background(), "perm_neverseen", - proto.PermissionDecisionPayload{Approved: true}) - if !errors.Is(err, agent.ErrUnknownPermission) { - t.Errorf("SubmitPermission for unknown id err = %v, want ErrUnknownPermission", err) - } -} - -func TestSessionPermissionRoundTrip(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_p", "approve me", "permission"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - // Drain until we see the permission_request, then approve it. - permID := "" - deadline := time.After(5 * time.Second) - var collected []proto.Envelope - for permID == "" { - select { - case env, ok := <-out: - if !ok { - t.Fatalf("out closed before permission_request; collected %d", len(collected)) - } - collected = append(collected, env) - if env.Type == "permission_request" { - if env.ID != "run_p" { - t.Fatalf("permission env.ID = %q, want run_p", env.ID) - } - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission request: %v", err) - } - permID = request.RequestID - } - case <-deadline: - t.Fatalf("timeout waiting for permission_request; collected %d", len(collected)) - } - } - if !strings.HasPrefix(permID, "perm_") { - t.Errorf("perm id wrong shape: %q", permID) - } - - if err := sess.SubmitPermission(context.Background(), permID, - proto.PermissionDecisionPayload{Approved: true}); err != nil { - t.Fatalf("SubmitPermission: %v", err) - } - - // Drain the rest. Expect to land at done with "allowed" content. - rest, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after approval") - } - all := append(collected, rest...) - final := all[len(all)-1] - if final.Type != "done" { - t.Errorf("final env type = %q, want done", final.Type) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.HasPrefix(done.Content, "allowed for ") { - t.Errorf("done.content = %q, want 'allowed for ...'", done.Content) - } - - // After resolution the perm id should no longer be known. - err = sess.SubmitPermission(context.Background(), permID, - proto.PermissionDecisionPayload{Approved: true}) - if !errors.Is(err, agent.ErrUnknownPermission) { - t.Errorf("second SubmitPermission err = %v, want ErrUnknownPermission", err) - } -} - -func TestSessionPermissionTimeoutDeniesInsteadOfHanging(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 150 * time.Millisecond - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_permission_timeout", "approve me", "permission"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - envs, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after permission timeout") - } - final := envs[len(envs)-1] - if final.Type != proto.TypeDone { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) - } - var done proto.DonePayload - if err := final.DecodePayload(&done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "denied for req_cc_42") { - t.Fatalf("timeout did not deny the request: %q", done.Content) - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - // Pure-image inbound (empty Prompt, non-empty Attachments) is a - // valid prompt today and must NOT be rejected. - out := make(chan proto.Envelope, 4) - _, err := claudecode.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "r0", Input: proto.TextInput("")}, - out, helperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt + no attachments") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := claudecode.NewSessionForTest(context.Background(), - helperReq("r0", "hi", "echo-success"), - nil, helperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := helperConfig() - cfg.ClaudeBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := claudecode.NewSessionForTest(context.Background(), - helperReq("r0", "hi", "echo-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func envTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, e := range envs { - out[i] = e.Type - } - return out -} - -func mustContain(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Errorf("expected %q in %v", needle, haystack) - } -} - -// TestSessionAskUserQuestionRoundTrip drives the full intercept → -// answer → tool_result loop through a real subprocess, so the test -// also catches stdin write / NDJSON-framing regressions the unit -// tests can't see. -func TestSessionAskUserQuestionRoundTrip(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 30 * time.Second - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_a", "ask me", "ask-question"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - askID := "" - deadline := time.After(5 * time.Second) - var collected []proto.Envelope - for askID == "" { - select { - case env, ok := <-out: - if !ok { - t.Fatalf("out closed before prompt_for_user_choice; collected %d", len(collected)) - } - collected = append(collected, env) - if env.Type == proto.TypePromptForUserChoice { - // env.ID is the run id; the ask id rides on the payload. - var p proto.PromptForUserChoicePayload - if err := env.DecodePayload(&p); err != nil { - t.Fatalf("decode prompt_for_user_choice payload: %v", err) - } - askID = p.AskID - } - case <-deadline: - t.Fatalf("timeout waiting for prompt_for_user_choice; collected %d", len(collected)) - } - } - if !strings.HasPrefix(askID, "ask_") { - t.Errorf("ask id wrong shape: %q", askID) - } - - if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"wrong"}}}, - }); err == nil { - t.Fatal("accepted a foreign question ID") - } - if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm delete"}}}, - }); err != nil { - t.Fatalf("SubmitPromptForUserChoice: %v", err) - } - - rest, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after ask answer") - } - all := append(collected, rest...) - final := all[len(all)-1] - if final.Type != "done" { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(all)) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "Confirm delete") { - t.Errorf("answer not echoed back through the fake claude loop: %q", done.Content) - } - - // Second submit must look unknown. - err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"x"}}}}) - if !errors.Is(err, agent.ErrUnknownAsk) { - t.Errorf("second SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) - } -} - -// TestSessionAskUserQuestionTimeoutSubmitsCancelled exercises the -// daemon-side timer. AskTimeout is set short so the watchdog fires -// before the human responds; the test then asserts the fake claude -// resumed with the canned "timeout" message (i.e. the timer wrote a -// successful tool_result, not an error). -func TestSessionAskUserQuestionTimeoutSubmitsCancelled(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 150 * time.Millisecond - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_to", "ask me", "ask-question"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - envs, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after timer fired") - } - final := envs[len(envs)-1] - if final.Type != "done" { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "10 minutes") { - t.Errorf("timeout sentence not echoed: %q", done.Content) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/skills.go b/apps/parsar-daemon/internal/agent/claudecode/skills.go deleted file mode 100644 index 9305d23e3..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/skills.go +++ /dev/null @@ -1,276 +0,0 @@ -package claudecode - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/google/uuid" -) - -// skillDescriptor is the daemon-side view of one server-sent skill entry -// under agent_options["skills"]. Wire-identical to pluginDescriptor. -type skillDescriptor struct { - Name string - Version string - DownloadURL string - SHA256 string -} - -// SkillInstallResult carries installed directories and warnings. Claude Code -// auto-scans its project root; other adapters register the returned root. -type SkillInstallResult struct { - SkillDirs []string - Warnings []string -} - -// installSkills materialises every skill under -// /.claude/skills//. Pipeline mirrors installPlugins; -// only the target subdir differs (Claude Code auto-registers skills -// from that path). -func installSkills( - ctx context.Context, - logger *slog.Logger, - workDir string, - skills []skillDescriptor, -) (SkillInstallResult, error) { - if len(skills) == 0 { - return SkillInstallResult{}, nil - } - if strings.TrimSpace(workDir) == "" { - return SkillInstallResult{}, errors.New("claudecode skills: workDir is required") - } - return installSkillsAtRoot(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills, "claudecode skills") -} - -// InstallManagedSkills decodes the portable agent_options["skills"] payload, -// materializes it below root, and removes entries that are no longer active. -func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { - if strings.TrimSpace(root) == "" { - return SkillInstallResult{}, errors.New("managed skills: root is required") - } - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return SkillInstallResult{}, err - } - defer unlock() - skills, decodeWarnings := decodeSkillDescriptors(raw) - result, err := installSkillsAtRootLocked(ctx, logger, root, skills, "managed skills") - result.Warnings = append(decodeWarnings, result.Warnings...) - if err != nil { - return result, err - } - if err := pruneManagedSkills(root, result.SkillDirs); err != nil { - return result, err - } - return result, nil -} - -func installSkillsAtRoot( - ctx context.Context, - logger *slog.Logger, - root string, - skills []skillDescriptor, - logLabel string, -) (SkillInstallResult, error) { - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return SkillInstallResult{}, err - } - defer unlock() - return installSkillsAtRootLocked(ctx, logger, root, skills, logLabel) -} - -func installSkillsAtRootLocked( - ctx context.Context, - logger *slog.Logger, - root string, - skills []skillDescriptor, - logLabel string, -) (SkillInstallResult, error) { - if logger == nil { - logger = obslog.Bg() - } - if len(skills) == 0 { - return SkillInstallResult{}, nil - } - - result := SkillInstallResult{} - for _, s := range skills { - if err := s.validate(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) - logger.Warn(logLabel+": invalid descriptor", "err", err.Error()) - continue - } - - dir := filepath.Join(root, s.Name) - cacheKey := filepath.Join(dir, ".cache-key") - expectedKey := s.cacheKey() - - if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { - logger.Info(logLabel+": cache hit", - "name", s.Name, "version", s.Version, "dir", dir) - result.SkillDirs = append(result.SkillDirs, dir) - continue - } - - // Same timeout / cap as plugins — they share the install pipeline. - perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) - err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s, logLabel) - cancel() - if err != nil { - result.Warnings = append(result.Warnings, - fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) - logger.Warn(logLabel+": install failed", - "name", s.Name, "version", s.Version, "err", err.Error()) - continue - } - result.SkillDirs = append(result.SkillDirs, dir) - logger.Info(logLabel+": installed", - "name", s.Name, "version", s.Version, "dir", dir) - } - return result, nil -} - -func pruneManagedSkills(root string, activeDirs []string) error { - entries, err := os.ReadDir(root) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("managed skills: read root %s: %w", root, err) - } - active := make(map[string]struct{}, len(activeDirs)) - for _, dir := range activeDirs { - active[filepath.Base(dir)] = struct{}{} - } - for _, entry := range entries { - if entry.Name() == ".tmp" { - continue - } - if _, ok := active[entry.Name()]; ok { - continue - } - path := filepath.Join(root, entry.Name()) - if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("managed skills: remove stale entry %s: %w", path, err) - } - } - return nil -} - -// installOneSkill: same shape as installOnePlugin, only target dir differs. -// Reuses fetchPluginZip / verifyPluginSHA256FromFD / extractPluginZipFromFD -// — the helpers are skill-agnostic and applying them to skill zips keeps -// the path-traversal / TOCTOU / SHA256 defences identical. -func installOneSkill( - ctx context.Context, - logger *slog.Logger, - root, dir, cacheKey, expectedKey string, - s skillDescriptor, - logLabel string, -) error { - tmpDir := filepath.Join(root, ".tmp") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return fmt.Errorf("mkdir tmp: %w", err) - } - - zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) - defer func() { - _ = os.Remove(zipPath) - }() - - fd, err := fetchPluginZip(ctx, s.DownloadURL, zipPath) - if err != nil { - return err - } - defer fd.Close() - - if err := verifyPluginSHA256FromFD(fd, s.SHA256); err != nil { - return err - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("seek: %w", err) - } - fi, err := fd.Stat() - if err != nil { - return fmt.Errorf("stat: %w", err) - } - - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("rm old dir: %w", err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir target: %w", err) - } - if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { - _ = os.RemoveAll(dir) - return err - } - - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { - logger.Warn(logLabel+": write cache key failed", - "path", cacheKey, "err", err.Error()) - } - return nil -} - -// decodeSkillDescriptors converts agent_options["skills"] into typed -// descriptors. Mirrors decodePluginDescriptors. -func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { - if raw == nil { - return nil, nil - } - items, ok := raw.([]any) - if !ok { - return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} - } - out := make([]skillDescriptor, 0, len(items)) - warnings := make([]string, 0) - for i, item := range items { - obj, ok := item.(map[string]any) - if !ok { - warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) - continue - } - s := skillDescriptor{ - Name: stringField(obj, "name"), - Version: stringField(obj, "version"), - DownloadURL: stringField(obj, "download_url"), - SHA256: stringField(obj, "sha256"), - } - if err := s.validate(); err != nil { - warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) - continue - } - out = append(out, s) - } - return out, warnings -} - -func (s skillDescriptor) validate() error { - if strings.TrimSpace(s.Name) == "" { - return errors.New("name is required") - } - if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { - return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) - } - if strings.TrimSpace(s.DownloadURL) == "" { - return errors.New("download_url is required") - } - if len(s.SHA256) != 64 { - return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) - } - return nil -} - -func (s skillDescriptor) cacheKey() string { - return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/version.go b/apps/parsar-daemon/internal/agent/claudecode/version.go deleted file mode 100644 index 19181a828..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/version.go +++ /dev/null @@ -1,32 +0,0 @@ -package claudecode - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" -) - -// InstallURL points to the official Claude Code install instructions. -// Surfaced by `oac-daemon connect` when the CLI is missing so the user -// has a clear next step instead of an opaque "exec: no such file". -const InstallURL = "https://docs.anthropic.com/claude/docs/claude-code" - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary -// cannot be located on PATH. Callers use errors.Is to distinguish -// "install Claude Code" from "Claude Code is broken". -var ErrCLINotFound = errors.New("claude CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. Empty binary defaults to binpath.ClaudeCode() — the same -// resolver the session spawn uses, so probe and spawn always agree. On -// missing binary the error wraps ErrCLINotFound; on other failures the -// wrapped error keeps the raw stderr. -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "claude", - DefaultBinary: binpath.ClaudeCode(), - MissingError: ErrCLINotFound, - }) -} diff --git a/apps/parsar-daemon/internal/agent/claudecode/version_test.go b/apps/parsar-daemon/internal/agent/claudecode/version_test.go deleted file mode 100644 index 304f6a1a8..000000000 --- a/apps/parsar-daemon/internal/agent/claudecode/version_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package claudecode_test - -import ( - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "claude", - DefaultBinary: "claude", - MissingError: claudecode.ErrCLINotFound, - Check: claudecode.CheckCLIAvailable, - }) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go deleted file mode 100644 index 972e8af08..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go +++ /dev/null @@ -1,67 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Cancel addresses this fixed Turn. Settlement remains observable after the caller detaches. -func (s *session) Cancel(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - select { - case <-s.settled: - return s.settlementErr - default: - } - if err := ctx.Err(); err != nil { - return err - } - if s.owner == nil { - return errors.New("claudesdk: Turn owner is unavailable") - } - s.cancelOnce.Do(func() { - close(s.cancelOutput) - s.owner.mu.Lock() - current := s.owner.active == s && !s.owner.invalid - s.owner.mu.Unlock() - 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 { - s.invalidate() - } - } - }) - select { - case <-s.settled: - return s.settlementErr - case <-ctx.Done(): - select { - case <-s.settled: - return s.settlementErr - default: - return ctx.Err() - } - } -} - -// CancellationOutcome is available after successful Cancel or terminal publication. -// Closing settled publishes the immutable snapshot; an unsettled result is unknown. -func (s *session) CancellationOutcome() proto.DonePayload { - select { - case <-s.settled: - return s.outcome - default: - return proto.DonePayload{} - } -} - -var _ agent.Turn = (*session)(nil) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go deleted file mode 100644 index b8e64ed3a..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go +++ /dev/null @@ -1,209 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := cancellationConfig(root, "wait") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - // A stopped consumer must not prevent native output draining or cancellation. - out := make(chan proto.Envelope) - running, err := NewFactory(config)(ctx, cancellationRequest(), out) - if err != nil { - t.Fatal(err) - } - defer running.Cancel(ctx) - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("missing native readiness barrier") - } - short, stop := context.WithTimeout(ctx, 50*time.Millisecond) - err = running.Cancel(short) - stop() - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("unsettled cancellation reported %v", err) - } - provider, ok := running.(interface{ CancellationOutcome() proto.DonePayload }) - if !ok { - t.Fatal("missing cancellation outcome provider") - } - if got := provider.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { - t.Fatal("unsettled outcome was exposed", got) - } - if err := running.(*session).Steer(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}); !errors.Is(err, agent.ErrSteeringInactive) { - t.Fatal("cancelled execution accepted steering", err) - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := running.Cancel(ctx); err != nil { - t.Fatal(err) - } - select { - case <-running.(*session).process.Done(): - default: - t.Fatal("successful cancellation preceded owned process release") - } - got := provider.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil || got.Usage.Tokens != nil { - t.Fatalf("lost drained cancellation outcome: %+v", got) - } - var done proto.DonePayload - for event := range out { - if event.Type == proto.TypeDone { - if err := event.DecodePayload(&done); err != nil { - t.Fatal(err) - } - // Router cleanup calls Cancel while it is still handling Done. - if err := running.Cancel(ctx); err != nil { - t.Fatal("completion cleanup waited on terminal delivery", err) - } - } - } - gotJSON, _ := json.Marshal(got) - doneJSON, _ := json.Marshal(done) - if !bytes.Equal(gotJSON, doneJSON) { - t.Fatal("Done differs from cancellation outcome", done) - } -} - -func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { - for _, mode := range []string{"failure", "wrong-identity", "before-identity"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 8) - running, err := NewFactory(cancellationConfig(root, mode))(ctx, cancellationRequest(), out) - if err != nil { - t.Fatal(err) - } - defer running.Cancel(ctx) - failed := false - var done proto.DonePayload - for event := range out { - if event.Type == proto.TypeError { - failed = true - } - if event.Type == proto.TypeDone { - _ = event.DecodePayload(&done) - } - } - if !failed { - t.Fatal("native failure was not reported") - } - if mode == "failure" { - if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Content != "partial" || done.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("verified failure outcome was lost", done) - } - } else if done.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatal("requested or mismatched native identity was exposed", done) - } - }) - } -} - -func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := cancellationConfig(root, "wait") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, cancellationRequest(), out) - if err != nil { - t.Fatal(err) - } - defer running.Cancel(ctx) - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("missing native readiness barrier") - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := running.Cancel(ctx); err != nil { - t.Fatal(err) - } - var text string - usage := 0 - for event := range out { - if event.Type == proto.TypeDelta { - var delta proto.DeltaPayload - if err := event.DecodePayload(&delta); err != nil { - t.Fatal(err) - } - text += delta.Delta - } - if event.Type == proto.TypeUsage { - usage++ - } - } - if text != "taildrained" || usage != 1 { - t.Fatalf("ready consumer lost drained observations: text %q, usage %d", text, usage) - } -} - -func cancellationConfig(root, mode string) Config { - return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} -} - -func cancellationRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} -} - -func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { - if mode == "cancellation-wait" { - emit(bridgeEvent{Type: "delta", Delta: "partial"}) - if !scanner.Scan() { - return - } - var cancel map[string]any - if json.Unmarshal(scanner.Bytes(), &cancel) != nil || cancel["type"] != "turn_cancel" { - os.Exit(9) - } - // These valid observations were in flight when cancellation started. - emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "tail"}) - for { - if _, err := os.Stat(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "release")); err == nil { - break - } - time.Sleep(time.Millisecond) - } - _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) - emit(bridgeEvent{Type: "delta", Delta: "drained"}) - emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) - emit(bridgeEvent{Type: "error", Code: "cancelled"}) - return - } - if mode != "cancellation-before-identity" { - id := request.Resume - if mode == "cancellation-wrong-identity" { - id = "wrong-session" - } - emit(bridgeEvent{Type: "input_ready", SessionID: id}) - emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "partial"}) - } - emit(bridgeEvent{Type: "error", Code: "execution_failed"}) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/contracts.go b/apps/parsar-daemon/internal/agent/claudesdk/contracts.go deleted file mode 100644 index 58cd240ab..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/contracts.go +++ /dev/null @@ -1,27 +0,0 @@ -package claudesdk - -import "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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.Steerer = (*session)(nil) - _ agent.FunctionResultSubmitter = (*session)(nil) - _ agent.PermissionResponder = (*session)(nil) - _ agent.UserChoiceResponder = (*session)(nil) - _ agent.WorkspaceReader = (*session)(nil) - _ agent.WorkspaceDirectoryLister = (*session)(nil) - _ agent.WorkspaceWriter = (*session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) - _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) -) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go b/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go deleted file mode 100644 index 1e7b69b27..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { - for _, mode := range []string{"valid", "unconfirmed", "wrong-session", "wrong-result", "success-usage", "cancelled", "unknown", "malformed-code", "after-terminal", "scanner-error", "process-error"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - s, err := NewFactory(config)(ctx, req, out) - if err != nil { - t.Fatal(err) - } - defer s.Cancel(context.Background()) - var failure proto.ErrorPayload - var done proto.DonePayload - usage, errors, dones := 0, 0, 0 - for event := range out { - switch event.Type { - case proto.TypeUsage: - usage++ - case proto.TypeError: - errors++ - _ = event.DecodePayload(&failure) - case proto.TypeDone: - dones++ - _ = event.DecodePayload(&done) - } - } - if mode == "unconfirmed" { - if _, err := s.(*session).AwaitSettlement(ctx); err == nil { - t.Fatal("native diagnostic fabricated confirmed Turn settlement") - } - } - want := "" - if mode == "valid" || mode == "unconfirmed" { - want = "authentication_error" - } - if errors != 1 || dones != 1 || usage != 1 || failure.Code != want || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { - t.Fatalf("lost evidence: %d/%d/%d %+v %+v", errors, dones, usage, failure, done) - } - }) - } -} - -func runClassifiedFailureHelper(request startRequest, mode string, encode func(bridgeEvent)) { - mode = strings.TrimPrefix(mode, "classified-") - encode(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - raw := strings.ReplaceAll(strings.ReplaceAll(usageFixture, `"subtype":"success"`, `"subtype":"error_during_execution"`), `"is_error":false`, `"is_error":true`) - if mode == "success-usage" { - raw = usageFixture - } - encode(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: "result", Usage: json.RawMessage(raw)}) - e := bridgeEvent{Type: "error", Code: "execution_failed", SessionID: request.Resume, ResultID: "result", EngineErrorCode: json.RawMessage(`"authentication_error"`)} - switch mode { - case "wrong-session": - e.SessionID = "foreign" - case "wrong-result": - e.ResultID = "foreign" - case "cancelled": - e.Code = "cancelled" - case "unknown": - e.EngineErrorCode = json.RawMessage(`"future"`) - case "malformed-code": - e.EngineErrorCode = json.RawMessage(`{"secret":"value"}`) - } - encode(e) - if mode == "unconfirmed" { - confirmed, reusable := false, false - encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, Reusable: &reusable, Reason: "native_execution_unavailable"}) - os.Exit(0) - } - if mode == "process-error" { - os.Exit(7) - } - if mode == "after-terminal" { - fmt.Fprintln(os.Stdout, `{"type":"delta","delta":"late"}`) - } - if mode == "scanner-error" { - fmt.Fprintln(os.Stdout, strings.Repeat("x", 2*1024*1024)) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go deleted file mode 100644 index 15ff569d7..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go +++ /dev/null @@ -1,124 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("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) - if err != nil { - t.Fatal(err) - } - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} - before, _ := json.Marshal(request) - controlled, _, err := prepare(config, request) - if err != nil { - t.Fatal(err) - } - after, _ := json.Marshal(request) - if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) { - t.Fatal("default controls changed native input, instructions, continuation or caller options") - } -} - -func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { - cases := map[string]proto.ExecutionControls{ - "empty": {}, "missing-search": {TextVerbosity: "medium"}, "missing-verbosity": {WebSearch: "disabled"}, - "cached-search": {WebSearch: "cached", TextVerbosity: "medium"}, - "live-search": {WebSearch: "live", TextVerbosity: "medium"}, - "unknown-search": {WebSearch: "invalid", TextVerbosity: "medium"}, - "low-verbosity": {WebSearch: "disabled", TextVerbosity: "low"}, - "high-verbosity": {WebSearch: "disabled", TextVerbosity: "high"}, - "unknown-verbosity": {WebSearch: "disabled", TextVerbosity: "invalid"}, - } - for name, controls := range cases { - t.Run(name, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ExecutionControls: &controls, AgentOptions: map[string]any{"model": "native-model"}} - _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) - if err == nil || !strings.Contains(err.Error(), "execution controls require") { - t.Fatal("unsupported controls did not fail at admission", err) - } - if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { - t.Fatal("unsupported controls reached native setup", err) - } - }) - } -} - -func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} - _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) - if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { - t.Fatal("MCP reached an unsupported environment", err) - } - if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { - t.Fatal("MCP reached native setup", err) - } -} - -func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), ObserveMessages: true, DisableSubagents: true, AgentOptions: map[string]any{"model": "model", "system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} - start, _, err := prepare(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." { - t.Fatal("native configuration changed") - } - request.ExecutionControls.OutputFormat.Schema = json.RawMessage(`{"type":"object","const":9007199254740993}`) - if _, _, err := prepare(config, request); err == nil { - t.Fatal("lossy schema accepted") - } - request.ExecutionControls.OutputFormat.Schema = schema - request.DisableSubagents = false - if _, _, err := prepare(config, request); err == nil { - t.Fatal("unqualified subagent combination accepted") - } -} - -func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, AgentOptions: map[string]any{"model": "model"}, FunctionTools: []proto.FunctionTool{ - {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) - if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) { - t.Fatal("function discovery changed native definitions", err) - } - request.DisableSubagents = false - if _, _, err := prepare(config, request); err == nil { - t.Fatal("unqualified combination admitted") - } - request.DisableSubagents = true - request.ToolSearch = false - if _, _, err := prepare(config, request); err == nil { - t.Fatal("deferred definitions became eager") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go deleted file mode 100644 index 39c73ea5e..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor.go +++ /dev/null @@ -1,318 +0,0 @@ -package claudesdk - -import ( - "context" - "encoding/json" - "errors" - "io" - "slices" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type executor struct { - mu sync.Mutex - base *session - start startRequest - active *session - ready chan error - done chan struct{} - invalid bool - nativeID string -} - -func NewExecutorFactory(config Config) agent.ExecutorFactory { - config.Env = slices.Clone(config.Env) - if config.Workspace != nil { - workspace := *config.Workspace - config.Workspace = &workspace - } - checked := &runtimeCheckCache{} - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if ctx == nil { - ctx = context.Background() - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, errors.New("claudesdk: Executor preparation cannot submit input") - } - start, env, err := prepareConfiguration(config, req) - if err != nil { - return nil, err - } - info, err := checked.check(ctx, config) - if err != nil { - return nil, err - } - if err = validateExecutorFeatures(info, start); err != nil { - return nil, err - } - start.Type = "executor_prepare" - base, err := launch(ctx, config, start, env) - 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 { - select { - case err = <-e.ready: - case <-ctx.Done(): - err = ctx.Err() - } - } - if err != nil { - closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if closeErr := e.Close(closeCtx); closeErr != nil { - return e, errors.Join(err, closeErr) - } - return nil, err - } - return e, nil - } -} - -func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { - if info.Protocol != 3 { - return errors.New("claudesdk: Executor bridge protocol is unavailable") - } - if start.Workspace != nil && !info.supportsWorkspacePreparation() { - return errors.New("claudesdk: workspace preparation is unavailable") - } - if start.OutputFormat != nil && (!info.SupportsStructuredOutput() || start.Workspace != nil && !info.SupportsWorkspaceStructuredOutput()) { - return errors.New("claudesdk: packaged runtime does not support workspace structured output") - } - if start.Subagents != nil && !info.SupportsSubagents() { - return errors.New("claudesdk: subagent resources are unavailable") - } - if start.Workspace != nil && start.observeFunctions && !info.supportsWorkspaceCommands() { - return errors.New("claudesdk: packaged runtime does not support workspace command observations") - } - if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { - return errors.New("claudesdk: workspace functions are unavailable") - } - if servers := start.declaredMCP(); len(servers) > 0 { - for _, server := range servers { - if start.Workspace != nil && server.ServerURL != "" && !info.SupportsWorkspaceMCP() { - return errors.New("claudesdk: workspace HTTP MCP is unavailable") - } - } - if !info.SupportsHTTPMCP() { - return errors.New("claudesdk: packaged runtime does not support HTTP MCP") - } - for _, server := range servers { - if server.Required && !info.SupportsHTTPMCPRequired() { - return errors.New("claudesdk: packaged runtime does not support required HTTP MCP") - } - if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { - return errors.New("claudesdk: packaged runtime does not support authenticated HTTP MCP") - } - } - } - return nil -} - -func (e *executor) write(value any) error { - e.base.writeMu.Lock() - defer e.base.writeMu.Unlock() - return json.NewEncoder(e.base.process.Stdin).Encode(value) -} - -func (e *executor) read() { - stderrDone := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, e.base.process.Stderr); close(stderrDone) }() - scanner := e.base.bridgeOutput() - ready := false - readyFailure := errors.New("claudesdk: Executor readiness failed") - for scanner.Scan() { - raw := append([]byte(nil), scanner.Bytes()...) - var event bridgeEvent - if json.Unmarshal(raw, &event) != nil { - e.base.process.Cancel() - break - } - if !ready { - if event.Type != "executor_ready" || event.Protocol != 3 || event.TurnID != "" { - if event.Type == "error" { - readyFailure = bridgeFailure(event.Code) - } - e.base.process.Cancel() - break - } - ready = true - e.ready <- nil - continue - } - e.mu.Lock() - turn := e.active - valid := turn != nil && !turn.nativeEnded && event.TurnID == turn.runID - if valid && event.Type == "turn_settled" { - turn.nativeEnded = true - } - e.mu.Unlock() - if !valid { - e.base.process.Cancel() - break - } - select { - case turn.frames <- raw: - case <-e.base.process.Context().Done(): - } - } - e.base.process.Cancel() - for scanner.Scan() { - } - <-stderrDone - _ = e.base.process.Wait() - e.base.stopWorkspaceReads() - e.base.stopWorkspaceDirectories() - e.mu.Lock() - e.invalid = true - if e.active != nil { - close(e.active.frames) - } - e.mu.Unlock() - if !ready { - e.ready <- readyFailure - } - close(e.done) -} - -func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if ctx == nil { - ctx = context.Background() - } - if strings.TrimSpace(run) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { - return nil, errors.New("claudesdk: Turn requires live context, identity, input and output") - } - e.mu.Lock() - if e.invalid || e.active != nil || e.base.process.Context().Err() != nil { - e.mu.Unlock() - return nil, errors.New("claudesdk: Executor is unavailable") - } - s := &session{owner: e, runID: run, process: e.base.process, writeMu: e.base.writeMu, frames: make(chan []byte, 64), functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{}), outputDone: make(chan struct{}), cancelOutput: make(chan struct{})} - start := e.start - start.Resume = e.nativeID - e.active = s - e.mu.Unlock() - 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}) - stopWrite() - if err != nil { - s.invalidate() - return s, errors.New("claudesdk: Turn input delivery is unknown") - } - return s, nil -} - -func (e *executor) finishTurn(s *session) { - e.mu.Lock() - if e.active != s { - e.mu.Unlock() - return - } - if native, _ := s.outcome.Metadata[proto.DoneMetaAgentSessionID].(string); native != "" { - if e.nativeID != "" && e.nativeID != native { - e.invalid = true - } else { - e.nativeID = native - } - } - if !s.turnSettlement.Reusable { - e.invalid = true - } - invalid := e.invalid - if invalid { - s.turnSettlement.Reusable = false - if s.turnSettlement.Reason == "" { - s.turnSettlement.Reason = "executor_invalidated" - } - } - e.active = nil - e.mu.Unlock() - if invalid { - e.base.process.Cancel() - <-e.done - } -} - -func (e *executor) retire() { - e.mu.Lock() - e.invalid = true - e.mu.Unlock() - e.base.process.Cancel() - <-e.done -} - -func (e *executor) Close(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - e.mu.Lock() - e.invalid = true - active := e.active - e.mu.Unlock() - e.base.process.Cancel() - select { - case <-e.done: - if active != nil { - select { - case <-active.outputDone: - case <-ctx.Done(): - return ctx.Err() - } - } - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - if ctx == nil { - ctx = context.Background() - } - select { - case <-s.outputDone: - return s.turnSettlement, errors.Join(s.settlementErr, s.outputErr) - case <-ctx.Done(): - return agent.TurnSettlement{}, ctx.Err() - } -} - -var _ agent.Executor = (*executor)(nil) -var _ agent.Turn = (*session)(nil) - -// A timed-out operation retains its original Turn identity. Its callback cannot -// retire a healthy successor after that operation has otherwise settled. -func (s *session) invalidate() { - if s.owner == nil { - s.process.Cancel() - return - } - s.owner.mu.Lock() - defer s.owner.mu.Unlock() - if s.owner.active == s { - s.owner.invalid = true - 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/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go deleted file mode 100644 index 5d1ff8959..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go +++ /dev/null @@ -1,328 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "context" - "encoding/json" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/contracttest" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPayload) { - t.Helper() - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - entry := filepath.Join(root, "worker") - if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { - t.Fatal(err) - } - return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}} -} - -func runPersistentExecutorHelper() { - root := os.Getenv("SDK_EXECUTOR_DIR") - if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { - file, _ := os.OpenFile(filepath.Join(root, "probes"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if file != nil { - _, _ = file.WriteString("probe\n") - _ = file.Close() - } - printlnReport := json.NewEncoder(os.Stdout) - _ = printlnReport.Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 3, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture"}) - return - } - scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() { - return - } - var prepare map[string]json.RawMessage - if json.Unmarshal(scanner.Bytes(), &prepare) != nil || string(prepare["type"]) != `"executor_prepare"` || prepare["input"] != nil || prepare["run_id"] != nil { - os.Exit(4) - } - _ = os.WriteFile(filepath.Join(root, "prepared"), []byte(strconv.Itoa(os.Getpid())), 0600) - encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } - encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) - if os.Getenv("SDK_EXECUTOR_MODE") == "block" { - time.Sleep(time.Hour) - return - } - var active, old string - settle := func(cancel bool) { - encode(bridgeEvent{Type: "input_closed", TurnID: active, SessionID: "native-persistent"}) - if cancel { - encode(bridgeEvent{Type: "error", TurnID: active, Code: "cancelled"}) - } else { - encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent", Text: active}) - } - confirmed, reusable, reason := true, true, "" - switch os.Getenv("SDK_EXECUTOR_MODE") { - case "unknown_cancel", "queued_cancel", "pending_function_unconfirmed": - confirmed, reusable, reason = false, false, "cancellation_unconfirmed" - case "confirmed_closed": - reusable, reason = false, "native_closed" - } - event := bridgeEvent{Type: "turn_settled", TurnID: active, Confirmed: &confirmed, Reusable: &reusable, Reason: reason} - if os.Getenv("SDK_EXECUTOR_MODE") == "missing_confirmation" { - event.Confirmed = nil - } - encode(event) - old, active = active, "" - } - for scanner.Scan() { - var command struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - InputID string `json:"input_id"` - Input proto.MessageInput `json:"input"` - } - if json.Unmarshal(scanner.Bytes(), &command) != nil { - return - } - switch command.Type { - case "turn_start": - if active != "" { - os.Exit(5) - } - active = command.TurnID - if os.Getenv("SDK_EXECUTOR_MODE") == "late" && old != "" { - encode(bridgeEvent{Type: "delta", TurnID: old, Delta: "late"}) - continue - } - encode(bridgeEvent{Type: "turn_started", TurnID: active}) - encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) - if strings.HasPrefix(os.Getenv("SDK_EXECUTOR_MODE"), "pending_function") { - encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) - } - if len(command.Input) > 0 && len(command.Input[0].Content) > 0 && command.Input[0].Content[0].Text != nil && *command.Input[0].Content[0].Text == "wait" { - continue - } - settle(false) - case "steer": - if os.Getenv("SDK_EXECUTOR_MODE") == "text_contract" { - encode(bridgeEvent{Type: "input_applied", TurnID: active, InputID: command.InputID}) - continue - } - // A full bridge write has happened, but no native input receipt exists. - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) - case "turn_cancel": - if active == command.TurnID { - settle(true) - } - } - } -} - -func consumeExecutorTurn(t *testing.T, owner agent.Executor, id, input string) (agent.Turn, <-chan proto.Envelope) { - t.Helper() - out := make(chan proto.Envelope, 16) - turn, err := owner.StartTurn(t.Context(), id, proto.TextInput(input), out) - if err != nil || turn == nil { - t.Fatalf("StartTurn: %v", err) - } - return turn, out -} -func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope, reusable bool) { - t.Helper() - for event := range out { - if event.Type == proto.TypeDone { - var done proto.DonePayload - _ = event.DecodePayload(&done) - if done.Metadata[proto.DoneMetaAgentSessionID] != "native-persistent" { - t.Fatal("native continuity missing", done) - } - } - } - settlement, err := turn.AwaitSettlement(t.Context()) - if err != nil || settlement.Reusable != reusable { - t.Fatalf("settlement: %+v %v", settlement, err) - } -} -func TestExecutorRetainsProcessAcrossTurnsAndCancellation(t *testing.T) { - config, req := persistentConfig(t, "") - req.AgentOptions["model_provider"] = map[string]any{ - "protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "fixture-key", - } - owner, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer owner.Close(context.Background()) - pid := owner.(*executor).base.process.Cmd.Process.Pid - first, out := consumeExecutorTurn(t, owner, "first", "hello") - awaitExecutorTurn(t, first, out, true) - next, out := consumeExecutorTurn(t, owner, "next", "wait") - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal(event.Type) - } - if err := first.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - if err := next.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - awaitExecutorTurn(t, next, out, true) - third, out := consumeExecutorTurn(t, owner, "third", "hello") - awaitExecutorTurn(t, third, out, true) - if owner.(*executor).base.process.Cmd.Process.Pid != pid { - t.Fatal("native process was replaced") - } - select { - case <-owner.(*executor).base.process.Done(): - t.Fatal("native process exited between Turns") - default: - } - if err := owner.Close(t.Context()); err != nil { - t.Fatal(err) - } - -} -func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { - config, req := persistentConfig(t, "late") - owner, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer owner.Close(context.Background()) - first, out := consumeExecutorTurn(t, owner, "first", "hello") - awaitExecutorTurn(t, first, out, true) - second, out := consumeExecutorTurn(t, owner, "second", "hello") - for frame := range out { - if frame.Type == proto.TypeDelta { - t.Fatal("old Turn event reached successor") - } - } - if _, err := second.AwaitSettlement(t.Context()); err == nil { - t.Fatal("unknown native outcome marked settled") - } - next := make(chan proto.Envelope, 1) - if turn, err := owner.StartTurn(t.Context(), "third", proto.TextInput("hello"), next); turn != nil || err == nil { - t.Fatal("invalid executor accepted input") - } -} -func TestExecutorCachesReadinessUntilInstalledArtifactChanges(t *testing.T) { - config, req := persistentConfig(t, "") - factory := NewExecutorFactory(config) - for range 2 { - owner, err := factory(t.Context(), req) - if err != nil { - t.Fatal(err) - } - if err := owner.Close(t.Context()); err != nil { - t.Fatal(err) - } - } - count := func() int { - raw, _ := os.ReadFile(filepath.Join(filepath.Dir(config.Entrypoint), "probes")) - return strings.Count(string(raw), "probe\n") - } - if count() != 1 { - t.Fatal("unchanged registered runtime was reprobed") - } - time.Sleep(time.Millisecond) - if err := os.WriteFile(config.Entrypoint, []byte("version-two-changed"), 0600); err != nil { - t.Fatal(err) - } - owner, err := factory(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer owner.Close(context.Background()) - if count() != 2 { - t.Fatal("changed installation reused stale readiness") - } -} - -func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { - config, req := persistentConfig(t, "block") - owner, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer owner.Close(context.Background()) - rejected := make(chan proto.Envelope, 1) - cancelled, stop := context.WithCancel(t.Context()) - stop() - if turn, err := owner.StartTurn(cancelled, "not-written", proto.TextInput("hello"), rejected); turn != nil || err == nil { - t.Fatal("cancelled admission reached native input") - } - select { - case <-rejected: - t.Fatal("nil Turn consumed caller output") - default: - } - e := owner.(*executor) - if err := e.base.process.Stdin.Close(); err != nil { - t.Fatal(err) - } - out := make(chan proto.Envelope, 8) - turn, err := owner.StartTurn(t.Context(), "unknown-write", proto.TextInput("hello"), out) - if turn == nil || err == nil { - t.Fatalf("attempted write must preserve unknown Turn ownership: %T %v", turn, err) - } - for range out { - } - if _, err := turn.AwaitSettlement(t.Context()); err == nil { - t.Fatal("lost input outcome became confirmed") - } -} - -func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { - config, req := persistentConfig(t, "block") - owner, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - defer owner.Close(context.Background()) - turn, out := consumeExecutorTurn(t, owner, "blocked", "hello") - e := owner.(*executor) - written := make(chan error, 1) - go func() { written <- e.write(map[string]string{"padding": strings.Repeat("x", 2*1024*1024)}) }() - deadline := time.Now().Add(time.Second) - for e.base.writeMu.TryLock() { - e.base.writeMu.Unlock() - if time.Now().After(deadline) { - t.Fatal("transport write never started") - } - time.Sleep(time.Millisecond) - } - ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) - defer cancel() - returned := make(chan error, 1) - go func() { returned <- turn.Cancel(ctx) }() - select { - case err := <-returned: - if err == nil { - t.Fatal("blocked cancellation invented settlement") - } - case <-time.After(time.Second): - t.Fatal("Cancel ignored its transport deadline") - } - <-written - for range out { - } -} - -func TestSharedTextLifecycle(t *testing.T) { - config, req := persistentConfig(t, "text_contract") - owner, err := NewExecutorFactory(config)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - contracttest.TextLifecycle(t, contracttest.TextFixture{ - Executor: owner, - CompleteInput: proto.TextInput("hello"), ActiveInput: proto.TextInput("wait"), SteeringInput: proto.TextInput("continue waiting"), - Ready: func(e proto.Envelope) bool { return e.Type == proto.TypeDelta }, - NativeOwner: func() string { return strconv.Itoa(owner.(*executor).base.process.Cmd.Process.Pid) }, - }) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go deleted file mode 100644 index 789a34a90..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go +++ /dev/null @@ -1,242 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "strings" - "time" -) - -func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { - defer close(s.outputDone) - defer s.owner.finishTurn(s) - defer close(out) - defer s.stopFunctions() - defer s.stopSteering() - var failure error - outputLost := false - terminalLost := false - emit := func(kind string, payload any) { - event, err := proto.NewEnvelope(kind, s.runID, payload) - if err != nil { - return - } - select { - case out <- event: - return - default: - } - var cancelled <-chan struct{} - if kind != proto.TypeDone && kind != proto.TypeError { - cancelled = s.cancelOutput - } - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - select { - case out <- event: - case <-cancelled: - outputLost = true - case <-timer.C: - outputLost = true - if kind == proto.TypeDone || kind == proto.TypeError { - terminalLost = true - } - s.invalidate() - } - } - var content strings.Builder - var result *bridgeEvent - var classifiedFailure error - var engineCode, failedResultID string - var usage proto.Usage - var usageSession string - usageIDs := map[string]bool{} - var sequence uint64 - terminal := false - mcp := mcpState{calls: map[string]proto.ToolObservation{}} - commands := commandState{calls: map[string]proto.ToolObservation{}} - settlementReceived := false - settlementConfirmed := false - cancelled := false - reusable := false - reason := "bridge_interrupted" - for raw := range s.frames { - var event bridgeEvent - if err := json.Unmarshal(raw, &event); err != nil || event.TurnID != s.runID { - failure = fmt.Errorf("claudesdk: invalid Turn event identity") - s.invalidate() - break - } - if event.Type == "turn_started" && !terminal { - continue - } - if event.Type == "turn_settled" { - if !terminal || event.Reusable == nil || event.Confirmed == nil || (*event.Reusable && !*event.Confirmed) || (!*event.Reusable && event.Reason == "") { - failure = fmt.Errorf("claudesdk: invalid Turn settlement") - s.invalidate() - break - } - settlementReceived, settlementConfirmed, reusable, reason = true, *event.Confirmed, *event.Reusable, event.Reason - break - } - if terminal { - failure = fmt.Errorf("claudesdk: invalid SDK bridge output") - s.invalidate() - break - } - switch event.Type { - case "command_observation": - if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { - failure = err - s.invalidate() - } - case "mcp_observation": - if err := mcp.receive(event, start, emit); err != nil { - failure = err - s.invalidate() - } - case "delta": - if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { - failure = fmt.Errorf("claudesdk: invalid message delta identity") - s.invalidate() - break - } - content.WriteString(event.Delta) - sequence++ - emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) - case "output_message": - message := event.Message - if !start.ObserveMessages || message == nil || message.ID == "" || - (message.Status != "in_progress" && message.Status != "completed") || - (message.Status == "completed") != (message.Text != nil) { - failure = fmt.Errorf("claudesdk: invalid message observation") - s.invalidate() - break - } - emit(proto.TypeOutputMessage, message) - case "function_call", "function_applied": - if err := s.receiveFunction(event, start, emit); err != nil { - failure = err - s.invalidate() - } - case "input_ready", "input_closed", "input_applied", "input_rejected": - if err := s.receiveInput(event, start); err != nil { - failure = err - s.invalidate() - } - case proto.TypeSubagentIdentity, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: - if start.Subagents == nil || !json.Valid(event.Fact) { - failure = fmt.Errorf("claudesdk: unrequested native child observation") - s.invalidate() - break - } - emit(event.Type, event.Fact) - case "usage": - if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || - (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { - failure = fmt.Errorf("claudesdk: invalid usage identity or duplicate result") - s.invalidate() - break - } - nextUsage, err := appendNativeUsage(usage, event.Usage) - failure = err - if failure != nil { - s.invalidate() - break - } - usage = nextUsage - usageIDs[event.ResultID] = true - usageSession = event.SessionID - failedResultID = "" - var nativeResult struct { - Subtype string `json:"subtype"` - IsError bool `json:"is_error"` - } - if json.Unmarshal(event.Usage, &nativeResult) == nil && (nativeResult.Subtype == "error_during_execution" || nativeResult.Subtype == "success" && nativeResult.IsError) { - failedResultID = event.ResultID - } - emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) - case "result": - if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { - failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") - s.settlementErr = failure - s.invalidate() - } else { - result = &event - } - terminal = true - case "error": - cancelled = event.Code == "cancelled" - failure = bridgeFailure(event.Code) - if event.Code == "execution_failed" && event.SessionID != "" && s.matchesInputSession(event.SessionID) && - event.SessionID == usageSession && event.ResultID != "" && event.ResultID == failedResultID { - var code string - _ = json.Unmarshal(event.EngineErrorCode, &code) - engineCode, _ = proto.NormalizeEngineFailure(code, nil) - classifiedFailure = failure - } - terminal = true - default: - failure = fmt.Errorf("claudesdk: unknown SDK bridge event") - s.invalidate() - } - if failure != nil && !terminal { - break - } - } - if !settlementReceived && failure == nil { - failure = fmt.Errorf("claudesdk: Turn settlement is missing") - } - if result == nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK result is missing") - } - // Close result admission before deciding which unanswered calls cancellation settled. - s.stopFunctions() - if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { - settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" - } - mcp.close(start, emit) - commands.close(start, emit) - s.stopSteering() - metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} - if id := s.inputSessionID(); id != "" { - metadata[proto.DoneMetaAgentSessionID] = id - } - if failure == nil { - content.Reset() - content.WriteString(result.Text) - metadata[proto.DoneMetaAgentSessionID] = result.SessionID - } - s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} - // Publish the observed cancellation outcome before terminal delivery. - if s.settlementErr != nil || !settlementReceived || !settlementConfirmed || !reusable || outputLost || s.process.Context().Err() != nil { - s.owner.retire() - reusable = false - if reason == "" { - reason = "bridge_interrupted" - } - } - if !settlementReceived || !settlementConfirmed { - s.settlementErr = fmt.Errorf("claudesdk: native Turn settlement is unconfirmed") - } - close(s.settled) - if failure != nil { - // A valid Turn boundary replaces the former process-exit boundary. - // A native diagnostic does not itself confirm cancellation or reuse. - if failure != classifiedFailure || !settlementReceived { - engineCode = "" - } - emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error(), Code: engineCode}) - } - emit(proto.TypeDone, s.outcome) - if outputLost { - s.owner.retire() - reusable, reason = false, "output_delivery_interrupted" - } - if terminalLost { - s.outputErr = fmt.Errorf("claudesdk: terminal output delivery failed") - } - s.turnSettlement = agent.TurnSettlement{Reusable: reusable, Reason: reason} -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions.go b/apps/parsar-daemon/internal/agent/claudesdk/functions.go deleted file mode 100644 index aaa56e61c..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions.go +++ /dev/null @@ -1,185 +0,0 @@ -package claudesdk - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type pendingFunction struct { - call proto.FunctionCallPayload - result *proto.FunctionResultPayload - receipt chan error - applied bool -} - -type functionState struct { - mu sync.Mutex - calls map[string]*pendingFunction - closed bool -} - -func validateFunctions(tools []proto.FunctionTool) error { - names := map[string]bool{} - for _, tool := range tools { - var schema struct { - Type string `json:"type"` - } - if strings.TrimSpace(tool.Name) == "" || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return fmt.Errorf("claudesdk: functions require unique names and object-root JSON schemas") - } - names[tool.Name] = true - } - return nil -} - -func (s *session) receiveFunction(event bridgeEvent, start startRequest, emit func(string, any)) error { - var call *proto.FunctionCallPayload - var observation *proto.ToolObservation - var receipt chan error - err := func() error { - s.functions.mu.Lock() - defer s.functions.mu.Unlock() - if s.functions.closed { - return agent.ErrUnknownFunctionCall - } - switch event.Type { - case "function_call": - call = event.Call - declared := false - if call != nil { - for _, tool := range start.Functions { - if tool.Name == call.Name { - declared = true - break - } - } - } - if !declared || call.CallID == "" || !json.Valid(call.Arguments) || s.functions.calls[call.CallID] != nil { - return fmt.Errorf("claudesdk: invalid function call") - } - s.functions.calls[call.CallID] = &pendingFunction{call: *call, receipt: make(chan error, 1)} - observation = &proto.ToolObservation{Kind: "function", Status: "in_progress", Name: call.Name, Arguments: call.Arguments} - case "function_applied": - pending := s.functions.calls[event.CallID] - if pending == nil || pending.result == nil || pending.applied || pending.result.DeliveryID != event.DeliveryID { - return fmt.Errorf("claudesdk: invalid native function receipt") - } - pending.applied = true - receipt = pending.receipt - status := "completed" - if !pending.result.Success { - status = "failed" - } - observation = &proto.ToolObservation{Kind: "function", Status: status, Name: pending.call.Name, Arguments: pending.call.Arguments, Content: &pending.result.Content} - } - return nil - }() - if err != nil { - return err - } - if start.observeFunctions { - id, stage := event.CallID, "after" - if call != nil { - id, stage = call.CallID, "before" - } - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: observation.Name, Stage: stage, Observation: observation}) - } - if call != nil { - emit(proto.TypeFunctionCall, call) - } else { - receipt <- nil - } - return nil -} - -func (s *session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { - if result.CallID == "" || result.DeliveryID == "" { - return fmt.Errorf("claudesdk: function result identities are required") - } - if err := result.ValidateContent(); err != nil { - return err - } - for _, part := range result.Content { - if part.Type == "input_image" && !result.Success { - return fmt.Errorf("claudesdk: native error results cannot retain images") - } - } - if err := (proto.MessageInput{{Content: result.Content}}).ValidateInlineImages(); err != nil { - return err - } - data, err := json.Marshal(struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - proto.FunctionResultPayload - }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) - if err != nil { - return err - } - if len(data) > 1024*1024 { - return fmt.Errorf("claudesdk: function result exceeds bridge input limit") - } - var owned proto.FunctionResultPayload - if err := json.Unmarshal(data, &owned); err != nil { - return err - } - s.functions.mu.Lock() - pending := s.functions.calls[result.CallID] - if s.functions.closed || s.process.Context().Err() != nil || pending == nil || pending.result != nil { - s.functions.mu.Unlock() - return agent.ErrUnknownFunctionCall - } - pending.result = &owned - s.functions.mu.Unlock() - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - // A lost receipt has an unknown outcome; terminate this execution instead of resending. - stop := context.AfterFunc(ctx, s.invalidate) - defer stop() - s.writeMu.Lock() - if err = ctx.Err(); err == nil { - _, err = s.process.Stdin.Write(append(data, '\n')) - } - s.writeMu.Unlock() - if err != nil { - s.invalidate() - return fmt.Errorf("claudesdk: function result transport failed") - } - select { - case err := <-pending.receipt: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (s *session) functionsComplete(cancelled bool) bool { - s.functions.mu.Lock() - defer s.functions.mu.Unlock() - for _, pending := range s.functions.calls { - if !pending.applied && !(cancelled && pending.result == nil) { - return false - } - } - return true -} - -func (s *session) stopFunctions() { - s.functions.mu.Lock() - defer s.functions.mu.Unlock() - if s.functions.closed { - return - } - s.functions.closed = true - for _, pending := range s.functions.calls { - if !pending.applied { - pending.receipt <- agent.ErrUnknownFunctionCall - } - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go deleted file mode 100644 index ef8763585..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go +++ /dev/null @@ -1,146 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestFunctionFactoryNativeReceipts(t *testing.T) { - for _, mode := range []string{"functions-success", "functions-wrong-receipt", "functions-no-receipt", "functions-cancel"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", ObserveToolObservations: true, AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) - if err != nil { - t.Fatal(err) - } - defer running.Cancel(context.Background()) - submitter := running.(agent.FunctionResultSubmitter) - submissions := make(chan error, 2) - calls := 0 - failed := false - complete := map[string]proto.ToolObservation{} - for event := range out { - if event.ID != "run" { - t.Fatal("wrong run") - } - switch event.Type { - case proto.TypeFunctionCall: - var call proto.FunctionCallPayload - if err := event.DecodePayload(&call); err != nil { - t.Fatal(err) - } - calls++ - invalid := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: true} - if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { - t.Fatal("missing content consumed call") - } - image := "https://example.invalid/image" - invalid.Content = []proto.InputContent{{Type: "input_image", ImageURL: &image}} - if err := submitter.SubmitFunctionResult(ctx, invalid); err == nil { - t.Fatal("image should fail before delivery") - } - first, second := "first-"+call.CallID, "second-"+call.CallID - value := proto.FunctionResultPayload{DeliveryID: "delivery-" + call.CallID, CallID: call.CallID, Success: call.CallID == "b", Content: []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} - go func() { submissions <- submitter.SubmitFunctionResult(ctx, value) }() - 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" { - t.Fatal("expected neutral observation") - } - if tool.Stage != "before" && tool.Stage != "after" { - t.Fatal("invalid shared tool stage") - } - if tool.Stage == "after" { - complete[tool.ID] = *tool.Observation - } - case proto.TypeDelta: - if mode == "functions-cancel" { - if err := running.Cancel(ctx); err == nil { - t.Fatal("unconfirmed function receipts became successful cancellation") - } - } - case proto.TypeError: - failed = true - } - } - if calls != 2 || failed != (mode != "functions-success") { - t.Fatalf("calls=%d failed=%v", calls, failed) - } - for range calls { - if err := <-submissions; (err == nil) != (mode == "functions-success") { - t.Fatalf("unexpected receipt: %v", err) - } - } - if mode == "functions-success" { - if len(complete) != 2 || complete["a"].Status != "failed" || complete["b"].Status != "completed" { - t.Fatalf("bad observations: %+v", complete) - } - for id, value := range complete { - if value.Content == nil || len(*value.Content) != 2 || *(*value.Content)[0].Text != "first-"+id || *(*value.Content)[1].Text != "second-"+id { - t.Fatal("lost ordered result") - } - } - } else if len(complete) != 0 { - t.Fatal("unconfirmed result acquired a completed observation") - } - }) - } -} - -func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { - if len(request.Functions) != 1 || request.Functions[0].Name != "lookup" || !strings.Contains(string(request.Functions[0].Parameters), `"items":{"type":"string"}`) { - os.Exit(4) - } - for _, id := range []string{"a", "b"} { - emit(bridgeEvent{Type: "function_call", Call: &proto.FunctionCallPayload{CallID: id, Name: "lookup", Arguments: json.RawMessage(`{"ids":["same"]}`)}}) - } - results := map[string]proto.FunctionResultPayload{} - for range 2 { - if !scanner.Scan() { - os.Exit(5) - } - var value proto.FunctionResultPayload - if json.Unmarshal(scanner.Bytes(), &value) != nil || value.ValidateContent() != nil || len(value.Content) != 2 { - os.Exit(6) - } - results[value.CallID] = value - } - if mode == "functions-cancel" { - emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) - if scanner.Scan() { - emit(bridgeEvent{Type: "error", Code: "cancelled"}) - } - return - } - if mode == "functions-no-receipt" { - return - } - for _, id := range []string{"b", "a"} { - delivery := results[id].DeliveryID - if mode == "functions-wrong-receipt" { - delivery = "wrong" - } - emit(bridgeEvent{Type: "function_applied", CallID: id, DeliveryID: delivery}) - } - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/harness_config_test.go b/apps/parsar-daemon/internal/agent/claudesdk/harness_config_test.go deleted file mode 100644 index e18447a1d..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/harness_config_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "path/filepath" - "testing" -) - -func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - native := map[string]any{"effort": "high", "thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} - req := proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "harness_config": native}} - start, _, err := prepareConfiguration(config, req) - if err != nil { - t.Fatal(err) - } - native["thinking"].(map[string]any)["budgetTokens"] = 2048 - raw, err := json.Marshal(start) - if err != nil { - t.Fatal(err) - } - var bridge map[string]any - if json.Unmarshal(raw, &bridge) != nil { - t.Fatal("invalid bridge request") - } - if _, exists := bridge["harness_config"]; exists { - t.Fatal("public configuration crossed the private bridge") - } - applied := bridge["native_model_options"].(map[string]any) - if applied["effort"] != "high" || applied["thinking"].(map[string]any)["budgetTokens"] != float64(1024) { - t.Fatal("bridge lost immutable native configuration") - } - req.AgentOptions["harness_config"] = map[string]any{"env": map[string]any{"ANTHROPIC_BASE_URL": "bypass"}} - if _, _, err = prepareConfiguration(config, req); err != harnessconfig.ErrHarnessConfig { - t.Fatalf("provider override accepted: %v", err) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/installation.go b/apps/parsar-daemon/internal/agent/claudesdk/installation.go deleted file mode 100644 index d4ed22a20..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/installation.go +++ /dev/null @@ -1,23 +0,0 @@ -package claudesdk - -import ( - "context" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "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" }, - 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} - }, - 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() { - return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") - } - return nil - }} -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go deleted file mode 100644 index de9be05b1..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go +++ /dev/null @@ -1,138 +0,0 @@ -package claudesdk - -import ( - "bytes" - "crypto/rand" - "encoding/json" - "fmt" - "net/url" - "regexp" - "slices" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) -var mcpTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) - -func validateMCP(req proto.PromptRequestPayload) error { - if req.MCPHTTPServers == nil { - return nil - } - if err := validateMCPServers(*req.MCPHTTPServers); err != nil { - return err - } - _, err := agent.ResolveMCPBindings(req) - return err -} - -func validateMCPServers(servers []proto.MCPHTTPServer) error { - labels := map[string]bool{} - for _, server := range servers { - endpoint, err := url.Parse(server.ServerURL) - if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || - err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || - strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { - return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") - } - if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { - return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") - } - labels[server.ServerLabel] = true - if server.AllowedTools != nil { - for _, name := range *server.AllowedTools { - if !mcpTool.MatchString(name) { - return fmt.Errorf("claudesdk: unsupported MCP tool allowlist") - } - } - } - } - return nil -} - -// Only generated references cross the private bridge; secrets stay in the owned -// process environment and are expanded by the native HTTP client. -type mcpHTTPServer struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url,omitempty"` - AllowedTools *[]string `json:"allowed_tools"` - Required bool `json:"required,omitempty"` - BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` -} - -func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { - if declarations == nil { - return nil, nil - } - servers := make([]mcpHTTPServer, len(*declarations)) - var env []string - for i, declaration := range *declarations { - server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} - if declaration.AllowedTools != nil { - tools := append([]string{}, (*declaration.AllowedTools)...) - server.AllowedTools = &tools - } - if declaration.BearerToken != nil { - server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() - env = append(env, server.BearerTokenEnvVar+"="+*declaration.BearerToken) - } - servers[i] = server - } - return &servers, env -} - -type mcpState struct { - calls map[string]proto.ToolObservation -} - -func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(string, any)) error { - n := event.Observation - if n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) || - !json.Valid(n.Output) || !json.Valid(n.Error) { - return fmt.Errorf("claudesdk: invalid MCP observation") - } - declared := false - for _, server := range start.declaredMCP() { - if server.ServerLabel == n.Server && n.Name != "" && (server.AllowedTools == nil || slices.Contains(*server.AllowedTools, n.Name)) { - declared = true - break - } - } - previous, exists := m.calls[event.ID] - if !declared || (event.Stage == "before" && (exists || n.Status != "in_progress")) || - (event.Stage == "after" && (!exists || previous.Status != "in_progress" || - (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete") || - previous.Server != n.Server || previous.Name != n.Name || !bytes.Equal(previous.Arguments, n.Arguments))) || - (event.Stage != "before" && event.Stage != "after") { - return fmt.Errorf("claudesdk: inconsistent MCP observation") - } - m.calls[event.ID] = *n - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: n.Name, Stage: event.Stage, Observation: n}) - } - return nil -} - -func (m *mcpState) complete() bool { - for _, call := range m.calls { - if call.Status == "in_progress" { - return false - } - } - return true -} - -func (m *mcpState) close(start startRequest, emit func(string, any)) { - for id, call := range m.calls { - if call.Status != "in_progress" { - continue - } - call.Status = "incomplete" - m.calls[id] = call - if start.observeFunctions { - emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: call.Name, Stage: "after", Observation: &call}) - } - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go deleted file mode 100644 index 0d6c23ce4..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "os" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" -) - -func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" - req := workspaceRequest() - token := "selected-user-token" - t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", NetworkAccess: "enabled", 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}, - }} - start, env, err := prepare(config, req) - if err != nil { - t.Fatal(err) - } - if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 || start.Workspace.CapabilityRoot != req.LocalEnvironment.CapabilityRoot { - t.Fatal("environment declarations changed authority") - } - stdio := start.Workspace.MCP[0] - executable, _ := os.Executable() - if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { - t.Fatal("stdio bypassed the shared installed entry") - } - raw, _ := json.Marshal(start) - for _, forbidden := range []string{token, "unselected-native-token", "untrusted-package-command", "package-argument", `"MCP_TOKEN"`} { - if strings.Contains(string(raw), forbidden) { - t.Fatal("private request contains package input or credential values") - } - } - reference := start.Workspace.MCP[1].BearerTokenEnvVar - found := false - for _, entry := range env { - if entry == reference+"="+token { - found = true - } - } - if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { - t.Fatal("selected credential or observation declarations missing") - } -} - -func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { - for _, mutate := range []func(*proto.LocalEnvironment){ - func(e *proto.LocalEnvironment) { e.NetworkAccess = "restricted" }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" }, - func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, - func(e *proto.LocalEnvironment) { - token := "token" - e.MCP[0].BearerToken = &token - e.MCP[0].Server.URL = "http://example.invalid/mcp" - }, - } { - environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} - mutate(environment) - if _, _, err := prepareRuntimeMCP(proto.PromptRequestPayload{LocalEnvironment: environment}); err == nil { - t.Fatal("unsupported declaration accepted") - } - } -} - -func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { - start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true} - state := mcpState{calls: map[string]proto.ToolObservation{}} - observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)} - var emitted []proto.ToolCallPayload - emit := func(_ string, payload any) { emitted = append(emitted, payload.(proto.ToolCallPayload)) } - if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil { - t.Fatal(err) - } - state.close(start, emit) - if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" { - t.Fatal("interrupted environment call lost identity") - } - observation.Server = "undeclared" - if err := state.receive(bridgeEvent{ID: "other", Stage: "before", Observation: &observation}, start, emit); err == nil { - t.Fatal("undeclared observation accepted") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go deleted file mode 100644 index 796077fd3..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestHTTPMCPDeclaration(t *testing.T) { - for _, mode := range []string{"unrestricted", "selected", "empty", "nil-slice", "required", "auth", "url-auth", "query", "wildcard", "reserved", "duplicate", "environment"} { - t.Run(mode, func(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")} - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} - tools := []string{"echo"} - switch mode { - case "selected": - servers[0].AllowedTools = &tools - case "empty": - tools = []string{} - servers[0].AllowedTools = &tools - case "nil-slice": - tools = nil - servers[0].AllowedTools = &tools - case "required": - servers[0].Required = true - case "auth": - token := "private" - servers[0].BearerToken = &token - case "url-auth": - servers[0].ServerURL = "https://user:secret@example.invalid/mcp" - case "query": - servers[0].ServerURL += "?" - case "wildcard": - tools = []string{"*"} - servers[0].AllowedTools = &tools - case "reserved": - servers[0].ServerLabel = "functions" - case "duplicate": - servers = append(servers, servers[0]) - case "environment": - req.DisableExecutionEnvironment = false - } - start, _, err := prepare(config, req) - valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" - if (err == nil) != valid { - t.Fatalf("unexpected admission: %v", err) - } - if !valid { - return - } - raw, _ := json.Marshal(start) - if mode == "required" && !strings.Contains(string(raw), `"required":true`) { - t.Fatal("required initialization was discarded") - } - if (mode == "empty" || mode == "nil-slice") && !strings.Contains(string(raw), `"allowed_tools":[]`) { - t.Fatal("empty selection widened") - } - if mode == "selected" { - tools[0] = "changed" - if (*(*start.MCPHTTPServers)[0].AllowedTools)[0] != "echo" { - t.Fatal("request did not snapshot tool selection") - } - } - }) - } -} - -func TestMCPObservationLifecycle(t *testing.T) { - start := startRequest{MCPHTTPServers: &[]mcpHTTPServer{{ServerLabel: "fixture"}}, observeFunctions: true} - state := mcpState{calls: map[string]proto.ToolObservation{}} - var observations []proto.ToolCallPayload - emit := func(kind string, payload any) { - if kind != proto.TypeToolCall { - t.Fatal(kind) - } - observations = append(observations, payload.(proto.ToolCallPayload)) - } - before := bridgeEvent{Type: "mcp_observation", ID: "native", Stage: "before", Observation: &proto.ToolObservation{Kind: "mcp", Status: "in_progress", Name: "echo", Server: "fixture", Arguments: json.RawMessage(`{"value":7}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)}} - if err := state.receive(before, start, emit); err != nil { - t.Fatal(err) - } - if state.complete() { - t.Fatal("unfinished call completed") - } - if err := state.receive(before, start, emit); err == nil { - t.Fatal("duplicate call accepted") - } - after := before - after.Stage = "after" - n := *before.Observation - after.Observation = &n - n.Status = "completed" - n.Output = json.RawMessage(`{"content":"value","structuredContent":{"number":7}}`) - if err := state.receive(after, start, emit); err != nil { - t.Fatal(err) - } - if !state.complete() || string(observations[1].Observation.Output) != string(n.Output) { - t.Fatal("native result changed") - } - before.ID = "unfinished" - if err := state.receive(before, start, emit); err != nil { - t.Fatal(err) - } - state.close(start, emit) - if !state.complete() || observations[len(observations)-1].Observation.Status != "incomplete" { - t.Fatal("cancellation lost pending call") - } - if err := state.receive(after, start, emit); err == nil { - t.Fatal("terminal call reopened") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go b/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go deleted file mode 100644 index e4a04b378..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/messages_test.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestMessageObservations(t *testing.T) { - for _, mode := range []string{"messages-success", "messages-partial", "messages-unrequested", "messages-missing-id", "messages-invalid-snapshot"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ObserveMessages: mode != "messages-unrequested", AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) - if err != nil { - t.Fatal(err) - } - defer running.Cancel(context.Background()) - var events []proto.Envelope - var failure bool - var done *proto.DonePayload - for event := range out { - events = append(events, event) - switch event.Type { - case proto.TypeError: - failure = true - case proto.TypeDone: - done = &proto.DonePayload{} - if err := json.Unmarshal(event.Payload, done); err != nil { - t.Fatal(err) - } - } - } - if done == nil || failure != (mode != "messages-success") { - t.Fatalf("unexpected outcome: %+v", events) - } - switch mode { - case "messages-success": - verifyMessageEvents(t, events, "partialfinal") - if done.Content != "partialfinal" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { - t.Fatalf("bad Done: %+v", done) - } - case "messages-partial": - if done.Content != "partial" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatalf("bad partial Done: %+v", done) - } - if len(events) != 4 || events[0].Type != proto.TypeOutputMessage || events[1].Type != proto.TypeDelta { - t.Fatalf("lost partial output: %+v", events) - } - case "messages-unrequested", "messages-missing-id": - if len(events) != 2 { - t.Fatalf("invalid bridge observation escaped: %+v", events) - } - } - }) - } -} - -func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) { - message := func(id, status string, text *string) { - emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: id, Status: status, Text: text}}) - } - if mode == "messages-missing-id" { - emit(bridgeEvent{Type: "delta", Delta: "unidentified"}) - return - } - if mode != "messages-unrequested" && !request.ObserveMessages { - os.Exit(4) - } - message("message-1", "in_progress", nil) - if mode == "messages-unrequested" { - return - } - emit(bridgeEvent{Type: "delta", ItemID: "message-1", Delta: "partial"}) - if mode == "messages-partial" { - emit(bridgeEvent{Type: "error", Code: "execution_failed"}) - return - } - if mode == "messages-invalid-snapshot" { - message("message-1", "completed", nil) - return - } - text := "partial" - message("message-1", "completed", &text) - message("message-2", "in_progress", nil) - emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "fi"}) - emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "nal"}) - text = "final" - message("message-2", "completed", &text) - emit(bridgeEvent{Type: "result", Text: "partialfinal", SessionID: request.Resume}) -} - -func verifyMessageEvents(t *testing.T, events []proto.Envelope, want string) { - t.Helper() - type observation struct { - text string - chunks int - completed bool - } - messages := map[string]*observation{} - var completed []string - var sequence uint64 - for _, event := range events { - switch event.Type { - case proto.TypeOutputMessage: - var value proto.OutputMessagePayload - if err := json.Unmarshal(event.Payload, &value); err != nil { - t.Fatal(err) - } - if value.ID == "" { - t.Fatal("missing message identity") - } - if value.Status == "in_progress" { - if messages[value.ID] != nil || value.Text != nil { - t.Fatal("duplicate or invalid message start") - } - messages[value.ID] = &observation{} - } else { - state := messages[value.ID] - if value.Status != "completed" || state == nil || state.completed || value.Text == nil || state.text != *value.Text { - t.Fatalf("incorrect completion snapshot: %+v, state %+v", value, state) - } - state.completed = true - completed = append(completed, *value.Text) - } - case proto.TypeDelta: - var value proto.DeltaPayload - if err := json.Unmarshal(event.Payload, &value); err != nil { - t.Fatal(err) - } - state := messages[value.ItemID] - if state == nil || state.completed || value.Sequence != sequence+1 { - t.Fatalf("unmatched/out-of-order delta: %+v", value) - } - sequence = value.Sequence - state.text += value.Delta - state.chunks++ - } - } - if len(messages) == 0 || strings.Join(completed, "") != want || sequence == 0 { - t.Fatalf("incomplete message stream: chunks=%d completed=%q want=%q", sequence, strings.Join(completed, ""), want) - } - for id, state := range messages { - if !state.completed { - t.Fatalf("message %s did not complete", id) - } - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go deleted file mode 100644 index 51dd4b9d6..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/options.go +++ /dev/null @@ -1,203 +0,0 @@ -package claudesdk - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - harnessconfiguration "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/claudesdk" -) - -type Config struct { - Node string - Entrypoint string - StateDir string - Env []string - Workspace *WorkspaceConfig -} - -type subagentOptions struct { - MaxConcurrent int `json:"max_concurrent"` -} - -type startRequest struct { - NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"` - ToolSearch bool `json:"tool_search,omitempty"` - 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"` - Resume string `json:"resume,omitempty"` - ObserveMessages bool `json:"observe_messages,omitempty"` - Functions []proto.FunctionTool `json:"functions,omitempty"` - MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` - Workspace *workspaceProfile `json:"workspace,omitempty"` - RequireHistory bool `json:"require_history,omitempty"` - observeFunctions bool -} - -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, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} - fail := func(reason string) (startRequest, []string, error) { - return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) - } - modelConfiguration, err := harnessconfiguration.Configuration().Prepare(req.AgentOptions) - if err != nil { - return startRequest{}, nil, err - } - start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) - if req.WorkspaceAuthoring { - return fail("requested capability is not available in the private SDK adapter") - } - if err := req.ValidateToolSearch(true); err != nil { - return startRequest{}, nil, err - } - if req.ToolSearch { - if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { - return fail("tool discovery requires the single-agent text/function profile") - } - start.ToolSearch = true - } - if err := validateMCP(req); err != nil { - return startRequest{}, nil, err - } - // Search is disabled by the fixed native tool profile. Medium selects the - // SDK's default text generation; it has no native verbosity-level option. - if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { - return fail("execution controls require disabled web search and medium text verbosity") - } - if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { - format := req.ExecutionControls.OutputFormat - if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { - return fail("structured output requires the qualified message-observing single-agent function profile") - } - if err := proto.ValidateBinary64Schema(format.Schema); err != nil { - return startRequest{}, nil, err - } - start.OutputFormat = format - } - if req.ObserveSubagentIdentities { - if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { - return fail("subagent execution does not support this tool combination") - } - limit := 6 - if req.MaxConcurrentSubagents != nil { - limit = *req.MaxConcurrentSubagents - } - if limit < 1 { - return fail("invalid concurrent subagent limit") - } - start.Subagents = &subagentOptions{MaxConcurrent: limit} - } - if err := validateFunctions(req.FunctionTools); err != nil { - return startRequest{}, nil, err - } - var provider []string - for name, raw := range req.AgentOptions { - if name == "harness_config" { - continue - } - if name == "model_provider" { - var err error - provider, err = providerEnvironment(raw) - if err != nil { - return startRequest{}, nil, err - } - continue - } - if name == "system_prompt" && raw == nil { - continue - } - value, ok := raw.(string) - if !ok { - return fail("model and system_prompt options must be strings") - } - switch name { - case "model": - start.Model = value - case "system_prompt": - start.SystemPrompt = value - default: - return fail("unsupported option: " + name) - } - } - if strings.TrimSpace(start.Model) == "" { - return fail("model is required") - } - if !filepath.IsAbs(config.Entrypoint) { - return fail("SDK entrypoint must be absolute") - } - config.Env = withProvider(config.Env, provider) - if config.Workspace != nil { - profile, env, err := prepareWorkspace(config, req) - if err != nil { - return startRequest{}, nil, err - } - start.Workspace = profile - start.Cwd = workspaceCwd(config.Workspace) - return start, env, nil - } - if req.LocalEnvironment != nil || req.RequireExistingNativeSession { - return fail("local execution and history recovery require a dedicated workspace") - } - root, err := paths.Root() - if err != nil { - return startRequest{}, nil, err - } - relative, err := filepath.Rel(root, config.StateDir) - if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fail("SDK state must be in a managed runtime subdirectory") - } - start.Cwd = req.WorkDir - if start.Cwd == "" { - start.Cwd = filepath.Join(config.StateDir, "work") - } - if strings.HasPrefix(start.Cwd, "~/") { - homeDir, err := os.UserHomeDir() - if err != nil { - return startRequest{}, nil, err - } - start.Cwd = filepath.Join(homeDir, strings.TrimPrefix(start.Cwd, "~/")) - } - if !filepath.IsAbs(start.Cwd) { - return fail("work_dir must be absolute or start with ~/") - } - for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { - if err := os.MkdirAll(dir, 0o700); err != nil { - return startRequest{}, nil, err - } - } - env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") - projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) - if err != nil { - return startRequest{}, nil, err - } - if req.MCPHTTPServers != nil { - servers := make([]mcpHTTPServer, 0, len(projectedMCP)) - for _, server := range projectedMCP { - servers = append(servers, server.mcpHTTPServer) - } - start.MCPHTTPServers = &servers - } - env = append(env, mcpEnv...) - return start, env, nil -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options_test.go b/apps/parsar-daemon/internal/agent/claudesdk/options_test.go deleted file mode 100644 index 36dddaa2b..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/options_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package claudesdk - -import ( - "path/filepath" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestNullableSystemPrompt(t *testing.T) { - for _, tc := range []struct { - name string - options map[string]any - want string - reject bool - }{ - {"omitted", map[string]any{"model": "test-model"}, "", false}, - {"null", map[string]any{"model": "test-model", "system_prompt": nil}, "", false}, - {"empty", map[string]any{"model": "test-model", "system_prompt": ""}, "", false}, - {"text", map[string]any{"model": "test-model", "system_prompt": "instructions"}, "instructions", false}, - {"null-model", map[string]any{"model": nil}, "", true}, - {"null-unknown", map[string]any{"model": "test-model", "unsupported": nil}, "", true}, - } { - t.Run(tc.name, func(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}) - 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/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go deleted file mode 100644 index 36b228698..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go +++ /dev/null @@ -1,116 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// prepared is a single admission for direct adapter callers. It uses the same -// Executor as pooled Runtime execution; workspace reads retain the owner. -type prepared struct { - mu sync.Mutex - executor *executor - session *session - binding *preparedStart - closed bool -} - -type preparedStart struct { - turn agent.Turn - done chan struct{} -} - -func NewPreparationFactory(config Config) agent.PreparationFactory { - factory := NewExecutorFactory(config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if config.Workspace == nil { - return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") - } - resource, err := factory(ctx, req) - if resource == nil { - return nil, err - } - e := resource.(*executor) - return &prepared{executor: e, session: e.base}, err - } -} - -func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return nil, errors.New("claudesdk: admission is unavailable") - } - binding := &preparedStart{done: make(chan struct{})} - p.binding = binding - p.mu.Unlock() - turn, err := p.executor.StartTurn(ctx, run, input, out) - p.mu.Lock() - binding.turn = turn - if turn == nil { - p.binding = nil - } - close(binding.done) - p.mu.Unlock() - if turn != nil { - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil - } - p.closed = true - p.mu.Unlock() - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - p.mu.Lock() - p.closed = true - binding := p.binding - p.mu.Unlock() - if binding == nil { - return p.executor.Close(ctx) - } - select { - case <-binding.done: - default: - if err := p.executor.Close(ctx); err != nil { - return err - } - select { - case <-binding.done: - case <-ctx.Done(): - return ctx.Err() - } - } - if binding.turn == nil { - return p.executor.Close(ctx) - } - if err := binding.turn.Cancel(ctx); err != nil { - return err - } - p.executor.retire() - return nil -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - defer p.mu.Unlock() - if p.binding == nil || p.binding.turn == nil { - return proto.DonePayload{} - } - return p.binding.turn.CancellationOutcome() -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go deleted file mode 100644 index 249937d82..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ /dev/null @@ -1,326 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "sync" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { - config := preparationFixture(t, "delayed-ready") - req := preparationRequest() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - result := make(chan agent.Prepared, 1) - failed := make(chan error, 1) - go func() { - p, err := NewPreparationFactory(config)(ctx, req) - if err != nil { - failed <- err - return - } - result <- p - }() - raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) - select { - case <-result: - t.Fatal("preparation returned before its native receipt") - case err := <-failed: - t.Fatal(err) - default: - } - if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { - t.Fatal(err) - } - var preparedResource agent.Prepared - select { - case preparedResource = <-result: - case err := <-failed: - t.Fatal(err) - case <-ctx.Done(): - t.Fatal(ctx.Err()) - } - p := preparedResource.(*prepared) - defer p.Cancel(context.Background()) - pid := p.session.process.Cmd.Process.Pid - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { - t.Fatal("preparation submitted input") - } - var frozen startRequest - 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 { - t.Fatal("configuration-only request was not retained") - } - config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" - config.Workspace.Directory = "/changed" - config.Workspace.Directory = "/changed" - req.AgentOptions["model"] = "changed" - req.AgentSessionID = "changed" - out := make(chan proto.Envelope, 16) - operation, stopOperation := context.WithCancel(ctx) - s, err := p.Start(operation, "actual-run", proto.TextInput("hello"), out) - stopOperation() - if err != nil { - t.Fatal(err) - } - if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { - t.Fatal("Start replaced the prepared native process") - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - if _, err := p.Start(ctx, "duplicate", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("duplicate Start was accepted") - } - var done proto.DonePayload - for event := range out { - if event.ID != "actual-run" || event.Type == proto.TypeError { - t.Fatal("lost execution identity or owner lifetime", event.Type) - } - if event.Type == proto.TypeDone { - if err := event.DecodePayload(&done); err != nil { - t.Fatal(err) - } - } - } - if done.Content != "completed" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("prepared execution lost ordinary output or frozen resume", done) - } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { - t.Fatal("completion preceded process release", err) - } -} - -func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "none", "functions", "mcp", "controls", "old-runtime"} { - t.Run(name, func(t *testing.T) { - config := preparationFixture(t, name) - req := preparationRequest() - switch name { - case "run": - req.RunID = "unexpected" - case "prompt": - req.Input = proto.TextInput("unexpected") - case "conversation": - req.ConversationID = "product" - case "attachments": - req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} - case "authoring": - req.WorkspaceAuthoring = true - case "subagents": - req.ObserveSubagentIdentities = true - case "workspace-missing": - config.Workspace = nil - case "none": - req.DisableExecutionEnvironment = true - case "functions": - req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} - case "mcp": - req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - case "controls": - req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} - } - if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { - t.Fatal("invalid preparation was accepted") - } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { - t.Fatal("rejection launched native preparation") - } - }) - } -} - -func TestPreparationFailureAndUnusedRelease(t *testing.T) { - for _, mode := range []string{"history-missing", "invalid-receipt", "close", "owner-cancel", "native-exit", "invalid-start", "cancelled-start"} { - t.Run(mode, func(t *testing.T) { - config := preparationFixture(t, mode) - owner, stop := context.WithCancel(t.Context()) - defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) - if mode == "history-missing" || mode == "invalid-receipt" { - if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { - t.Fatal("preparation failure was lost", err) - } - return - } - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - switch mode { - case "close": - err = p.Close() - case "owner-cancel": - stop() - case "native-exit": - err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) - case "invalid-start", "cancelled-start": - operation, cancel := context.WithCancel(t.Context()) - prompt := "" - if mode == "cancelled-start" { - prompt = "hello" - cancel() - } - _, startErr := p.Start(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) - cancel() - if startErr == nil { - t.Fatal("invalid or cancelled Start succeeded") - } - err = p.Close() - } - if err != nil { - t.Fatal(err) - } - select { - case <-p.executor.done: - case <-time.After(5 * time.Second): - t.Fatal("unused process was not settled") - } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("released preparation was reusable") - } - if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { - t.Fatal("unstarted process fabricated an execution outcome", got) - } - }) - } -} - -func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { - config := preparationFixture(t, "cancellation") - owner, stop := context.WithTimeout(t.Context(), 10*time.Second) - defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - out := make(chan proto.Envelope) - operation, stopOperation := context.WithCancel(owner) - if _, err := p.Start(operation, "run", proto.TextInput("hello"), out); err != nil { - t.Fatal(err) - } - stopOperation() - if err := p.Close(); err != nil { - t.Fatal(err) - } - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("operation cancellation or Close cancelled the Session") - } - short, cancel := context.WithTimeout(owner, 30*time.Millisecond) - err = p.Cancel(short) - cancel() - if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { - t.Fatal("pending output drain reported settled ownership", err) - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := p.Cancel(owner); err != nil { - t.Fatal(err) - } - got := p.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("cancellation across transfer lost observed output", got) - } - for range out { - } -} - -func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { - for _, cancelResource := range []bool{false, true} { - for range 6 { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - out := make(chan proto.Envelope, 16) - var running agent.Session - var startErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - running, startErr = p.Start(t.Context(), "run", proto.TextInput("hello"), out) - }() - go func() { - defer wg.Done() - if cancelResource { - _ = p.Cancel(t.Context()) - } else { - _ = p.Close() - } - }() - wg.Wait() - if startErr == nil { - if running == nil { - t.Fatal("successful transfer lost its Session") - } - for range out { - } - } - if err := p.Cancel(t.Context()); err != nil { - // A racing Close can confirm resource cleanup without proving the Turn result. - if closeErr := p.executor.Close(t.Context()); closeErr != nil { - t.Fatal(closeErr) - } - } - select { - case <-p.session.process.Done(): - default: - t.Fatal("race left the owned process alive") - } - } - } -} - -func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - results := make(chan bool, 2) - var wg sync.WaitGroup - for range 2 { - wg.Add(1) - go func() { - defer wg.Done() - out := make(chan proto.Envelope, 16) - _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) - results <- err == nil - if err == nil { - for event := range out { - if event.Type == proto.TypeError { - t.Error("losing Start cancelled the transferred Session") - } - } - } - }() - } - wg.Wait() - if first, second := <-results, <-results; first == second { - t.Fatal("concurrent Start did not produce exactly one owner") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go deleted file mode 100644 index bf17ca3dc..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/provider.go +++ /dev/null @@ -1,30 +0,0 @@ -package claudesdk - -import ( - "strings" - - harnessconfiguration "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/claudesdk" -) - -// Validate the frozen native provider before producing launch variables. -func providerEnvironment(value any) ([]string, error) { - provider, err := harnessconfiguration.Configuration().ParseProvider(value) - if err != nil { - return nil, err - } - return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil -} - -func withProvider(env, provider []string) []string { - if provider == nil { - return env - } - out := make([]string, 0, len(env)+len(provider)) - for _, entry := range env { - key, _, _ := strings.Cut(entry, "=") - if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" { - out = append(out, entry) - } - } - return append(out, provider...) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go deleted file mode 100644 index f8d7a1f82..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ /dev/null @@ -1,120 +0,0 @@ -package claudesdk - -import ( - "context" - "encoding/json" - "fmt" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type session struct { - owner *executor - runID string - frames chan []byte - outputDone chan struct{} - turnSettlement agent.TurnSettlement - settlementErr error - outputErr error - cancelOnce sync.Once - 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 -} - -func NewFactory(config Config) agent.Factory { - prepareExecutor := NewExecutorFactory(config) - return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - run, input := req.RunID, req.Input - req.RunID, req.ConversationID, req.Input = "", "", nil - resource, err := prepareExecutor(ctx, req) - if err != nil { - return nil, err - } - turn, err := resource.StartTurn(ctx, run, input, out) - if turn == nil { - _ = resource.Close(context.Background()) - return nil, err - } - // Factory callers own one execution. Both entrypoints use the same - // Executor implementation; Runtime pooling uses NewExecutorFactory. - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = resource.Close(context.Background()) }() - return turn, err - } -} - -type bridgeEvent struct { - EngineErrorCode json.RawMessage `json:"engine_error_code"` - TurnID string `json:"turn_id"` - Reusable *bool `json:"reusable"` - Confirmed *bool `json:"confirmed"` - Reason string `json:"reason"` - Protocol int `json:"protocol"` - - Fact json.RawMessage `json:"fact"` - InputID string `json:"input_id"` - ResultID string `json:"result_id"` - Usage json.RawMessage `json:"usage,omitempty"` - Type string `json:"type"` - Delta string `json:"delta"` - SessionID string `json:"session_id"` - Text string `json:"text"` - Code string `json:"code"` - ItemID string `json:"item_id"` - Message *proto.OutputMessagePayload `json:"message"` - Call *proto.FunctionCallPayload `json:"call"` - CallID string `json:"call_id"` - DeliveryID string `json:"delivery_id"` - ID string `json:"id"` - Stage string `json:"stage"` - Observation *proto.ToolObservation `json:"observation"` -} - -func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { - binary := config.Node - if binary == "" { - binary = "node" - } - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) - if err != nil { - return nil, err - } - return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil -} - -func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) (error, bool) { - for scanner.Scan() { - } - if scanner.Err() != nil { - failure = fmt.Errorf("claudesdk: SDK bridge output read failed") - s.process.Cancel() - } - <-stderrDone - s.stopWorkspaceReads() - s.stopWorkspaceDirectories() - waitErr := s.process.Wait() - if waitErr != nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK process failed") - } - return failure, scanner.Err() == nil && waitErr == nil -} - -func bridgeFailure(code string) error { - switch code { - case "invalid_request", "history_unavailable", "execution_failed", "cancelled": - return fmt.Errorf("claudesdk: %s", code) - default: - return fmt.Errorf("claudesdk: unknown SDK bridge failure") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go deleted file mode 100644 index 2ced109bb..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go +++ /dev/null @@ -1,189 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestTextFactoryCompletionAndFailures(t *testing.T) { - for _, mode := range []string{"success", "wrong-resume", "missing", "malformed", "process-failed", "after-result", "bridge-error"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - s, err := NewFactory(config)(ctx, request, out) - if err != nil { - t.Fatal(err) - } - defer s.Cancel(context.Background()) - failed := false - done := false - deltas := "" - for event := range out { - if event.ID != "run" { - t.Fatal("wrong run identity") - } - switch event.Type { - case proto.TypeDelta: - var payload proto.DeltaPayload - _ = json.Unmarshal(event.Payload, &payload) - if payload.ItemID != "" { - t.Fatal("ordinary deltas acquired message identity") - } - deltas += payload.Delta - case proto.TypeOutputMessage: - t.Fatal("ordinary requests acquired message observations") - case proto.TypeError: - failed = true - case proto.TypeDone: - done = true - var payload proto.DonePayload - _ = json.Unmarshal(event.Payload, &payload) - if mode == "success" { - if payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { - t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) - } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { - t.Fatal("Done preceded process release") - } - } else if payload.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatal("failed result exposed a successful continuity id") - } - } - } - if !done || failed != (mode != "success") { - t.Fatalf("done=%t failed=%t", done, failed) - } - }) - } -} - -func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { - for _, kind := range []string{"execution-controls", "tool", "option", "relative", "outside"} { - t.Run(kind, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentOptions: map[string]any{"model": "fake"}} - switch kind { - case "execution-controls": - request.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "low"} - case "tool": - request.FunctionTools = []proto.FunctionTool{{}} - case "option": - request.AgentOptions["allowed_tools"] = "anything" - case "relative": - request.WorkDir = "relative" - case "outside": - config.StateDir = filepath.Dir(root) - } - _, err := NewFactory(config)(context.Background(), request, make(chan proto.Envelope, 1)) - if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { - t.Fatalf("expected pre-launch rejection, got %v", err) - } - }) - } -} - -func TestMain(m *testing.M) { - if os.Getenv("GO_CLAUDE_EXECUTOR_HELPER") == "1" { - runPersistentExecutorHelper() - os.Exit(0) - } - if os.Getenv("GO_CLAUDE_PREPARATION_HELPER") == "1" { - runPreparationHelper() - os.Exit(0) - } - if os.Getenv("GO_CLAUDE_READINESS_HELPER") == "1" { - runReadinessHelper() - os.Exit(0) - } - if os.Getenv("GO_CLAUDE_SDK_HELPER") == "1" { - runSDKHelper() - os.Exit(0) - } - os.Exit(m.Run()) -} - -func runSDKHelper() { - if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { - fmt.Fprintln(os.Stdout, strings.TrimSuffix(readyReport, "}")+`,"features":["structured_output","subagents"]}`) - return - } - scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() { - os.Exit(2) - } - var request startRequest - if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "executor_prepare" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { - os.Exit(3) - } - encode, finish := helperTurn(scanner, &request) - defer finish() - mode := os.Getenv("SDK_HELPER_MODE") - if strings.HasPrefix(mode, "cancellation-") { - runCancellationHelper(request, mode, scanner, encode) - return - } - if strings.HasPrefix(mode, "steering-") { - runSteeringHelper(request, mode, scanner, encode) - return - } - if strings.HasPrefix(mode, "classified-") { - runClassifiedFailureHelper(request, mode, encode) - return - } - if strings.HasPrefix(mode, "usage-") { - runUsageHelper(request, mode, encode) - return - } - if strings.HasPrefix(mode, "functions-") { - runFunctionHelper(request, mode, scanner, encode) - return - } - if strings.HasPrefix(mode, "messages-") { - runMessageHelper(request, mode, encode) - return - } - switch mode { - case "missing": - return - case "malformed": - fmt.Fprintln(os.Stdout, "malformed") - time.Sleep(time.Hour) - return - case "bridge-error": - encode(bridgeEvent{Type: "error", Code: "execution_failed"}) - return - } - encode(bridgeEvent{Type: "delta", Delta: "partial"}) - id := request.Resume - if mode == "wrong-resume" { - id = "different-session" - } - encode(bridgeEvent{Type: "result", Text: "final", SessionID: id}) - if mode == "process-failed" { - os.Exit(7) - } - if mode == "after-result" { - encode(bridgeEvent{Type: "delta", Delta: "too late"}) - return - } - time.Sleep(50 * time.Millisecond) - _ = os.WriteFile(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "released"), nil, 0o600) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering.go b/apps/parsar-daemon/internal/agent/claudesdk/steering.go deleted file mode 100644 index c9d2c0eae..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering.go +++ /dev/null @@ -1,160 +0,0 @@ -package claudesdk - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type pendingInput struct { - id string - receipt chan error -} - -type steeringState struct { - mu sync.Mutex - sessionID string - closed bool - pending *pendingInput - seen map[string]bool -} - -var _ agent.Steerer = (*session)(nil) - -// Steer waits for native consumption, which may occur in a later native turn -// within this one SDK query. A completed stdin write is not a receipt. -func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, input, nil) -} - -// SteerWithReceipt separates a complete bridge write from native consumption. -func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { - select { - case <-s.cancelOutput: - return agent.ErrSteeringInactive - default: - } - if ctx == nil { - ctx = context.Background() - } - 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}) - if err != nil || len(data) > 1024*1024 { - return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) - } - s.steering.mu.Lock() - if s.steering.closed || s.process.Context().Err() != nil { - s.steering.mu.Unlock() - return agent.ErrSteeringInactive - } - if s.steering.sessionID == "" { - s.steering.mu.Unlock() - return agent.ErrSteeringNotReady - } - if s.steering.pending != nil || s.steering.seen[input.InputID] || len(s.steering.seen) >= 63 { - s.steering.mu.Unlock() - return fmt.Errorf("%w: input is pending, repeated or over capacity", agent.ErrSteeringRejected) - } - if err := ctx.Err(); err != nil { - s.steering.mu.Unlock() - return err - } - if s.steering.seen == nil { - s.steering.seen = map[string]bool{} - } - pending := &pendingInput{id: input.InputID, receipt: make(chan error, 1)} - s.steering.seen[input.InputID] = true - s.steering.pending = pending - s.steering.mu.Unlock() - // Cancellation must release a blocked write, but a lost receipt after a full - // write preserves the process and unknown outcome without automatic redelivery. - stop := context.AfterFunc(ctx, s.invalidate) - s.writeMu.Lock() - if err = ctx.Err(); err == nil { - _, err = s.process.Stdin.Write(append(data, '\n')) - } - s.writeMu.Unlock() - stop() - if err != nil { - s.invalidate() - return fmt.Errorf("claudesdk: input transport failed") - } - if written != nil { - written() - } - select { - case err := <-pending.receipt: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (s *session) receiveInput(event bridgeEvent, start startRequest) error { - s.steering.mu.Lock() - defer s.steering.mu.Unlock() - switch event.Type { - case "input_ready": - if s.steering.closed || s.steering.sessionID != "" || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume { - return fmt.Errorf("claudesdk: invalid input session identity") - } - s.steering.sessionID = event.SessionID - case "input_closed": - if s.steering.closed || s.steering.sessionID == "" || event.SessionID != s.steering.sessionID { - return fmt.Errorf("claudesdk: invalid input closure") - } - s.steering.closed = true - case "input_applied", "input_rejected": - pending := s.steering.pending - if pending == nil || pending.id != event.InputID { - return fmt.Errorf("claudesdk: invalid input receipt") - } - var err error - if event.Type == "input_rejected" { - err = agent.ErrSteeringRejected - } - pending.receipt <- err - s.steering.pending = nil - } - return nil -} - -func (s *session) steeringComplete() bool { - s.steering.mu.Lock() - defer s.steering.mu.Unlock() - return s.steering.pending == nil -} - -func (s *session) stopSteering() { - s.steering.mu.Lock() - defer s.steering.mu.Unlock() - s.steering.closed = true - if s.steering.pending != nil { - s.steering.pending.receipt <- fmt.Errorf("claudesdk: execution ended with unknown input outcome") - s.steering.pending = nil - } -} - -func (s *session) matchesInputSession(id string) bool { - s.steering.mu.Lock() - defer s.steering.mu.Unlock() - return s.steering.sessionID == "" || s.steering.sessionID == id -} - -func (s *session) inputSessionID() string { - s.steering.mu.Lock() - defer s.steering.mu.Unlock() - return s.steering.sessionID -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go deleted file mode 100644 index 7bd7f68d3..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go +++ /dev/null @@ -1,213 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestSteeringReceiptsAndLifecycle(t *testing.T) { - for _, mode := range []string{"success", "phased", "timeout", "wrong-receipt", "duplicate-usage", "cancel", "blocked-write"} { - t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} - if mode == "phased" { - config.Env[1] = "SDK_HELPER_MODE=steering-timeout" - } - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out := make(chan proto.Envelope, 16) - running, err := NewFactory(config)(ctx, request, out) - if err != nil { - t.Fatal(err) - } - s := running.(*session) - defer s.Cancel(context.Background()) - if frame := <-out; frame.Type != proto.TypeDelta { - t.Fatal("missing ready barrier", frame.Type) - } - input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("additional")} - if mode == "blocked-write" { - input.Input = proto.TextInput(strings.Repeat("x", 512*1024)) - } - receiptCtx, receiptCancel := context.WithTimeout(ctx, time.Second) - if mode == "timeout" { - receiptCancel() - receiptCtx, receiptCancel = context.WithTimeout(ctx, 30*time.Millisecond) - } - if mode == "blocked-write" { - receiptCancel() - receiptCtx, receiptCancel = context.WithCancel(ctx) - } - defer receiptCancel() - reply := make(chan error, 1) - go func() { - if mode == "phased" { - callCtx, cancel := context.WithCancel(ctx) - defer cancel() - timer := time.AfterFunc(30*time.Millisecond, cancel) - defer timer.Stop() - reply <- s.SteerWithReceipt(callCtx, input, func() { - if !timer.Stop() { - t.Error("write phase exceeded deadline") - } - }) - } else { - reply <- s.Steer(receiptCtx, input) - } - }() - if mode == "blocked-write" { - // Serialization can exceed a short deadline under race instrumentation. - // Cancel only after admission so this exercises transport cancellation. - for { - s.steering.mu.Lock() - admitted := s.steering.pending != nil - s.steering.mu.Unlock() - if admitted { - receiptCancel() - break - } - select { - case <-ctx.Done(): - t.Fatal("input was not admitted before test deadline") - case <-time.After(time.Millisecond): - } - } - } - if mode == "cancel" { - time.Sleep(30 * time.Millisecond) - _ = s.Cancel(context.Background()) - } - receipt := <-reply - if mode == "success" || mode == "phased" || mode == "duplicate-usage" { - if receipt != nil { - t.Fatal(receipt) - } - } else if receipt == nil { - t.Fatal("missing failure/unknown receipt") - } - if mode == "timeout" { - if !errors.Is(receipt, context.DeadlineExceeded) { - t.Fatal(receipt) - } - select { - case <-s.process.Done(): - t.Fatal("receipt timeout killed native process") - default: - } - } - var done proto.DonePayload - failed := false - measurements := 0 - for frame := range out { - switch frame.Type { - case proto.TypeError: - failed = true - case proto.TypeUsage: - measurements++ - case proto.TypeDone: - if err := frame.DecodePayload(&done); err != nil { - t.Fatal(err) - } - } - } - wantSuccess := mode == "success" || mode == "phased" || mode == "timeout" - if failed == wantSuccess { - t.Fatalf("unexpected terminal failure=%v", failed) - } - if wantSuccess { - if measurements != 2 || done.Content != "final" || done.Metadata[proto.DoneMetaAgentSessionID] != "native" { - t.Fatalf("bad completion: %+v, measurements=%d", done, measurements) - } - snapshots, ok := done.Usage.Raw["claude_sdk_results"].([]any) - if !ok || len(snapshots) != 2 { - t.Fatal("lost native-turn usage snapshots", done.Usage.Raw) - } - if snapshots[0].(map[string]any)["total_cost_usd"] != float64(0.1) || snapshots[1].(map[string]any)["total_cost_usd"] != float64(0.3) { - t.Fatal("native cumulative counters changed", snapshots) - } - } - if mode == "duplicate-usage" && measurements != 1 { - t.Fatal("duplicate measurement was published") - } - if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { - t.Fatal("completed execution accepted input", err) - } - if _, err := s.AwaitSettlement(ctx); err != nil && (mode == "success" || mode == "phased" || mode == "timeout") { - t.Fatal(err) - } - if err := s.owner.Close(ctx); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestSteeringDoesNotSendBeforeReadiness(t *testing.T) { - s := &session{process: &clirunner.Process{}} - if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringNotReady) { - t.Fatal(err) - } - s.stopSteering() - if err := s.Steer(context.Background(), proto.PromptSteerPayload{InputID: "one", Input: proto.TextInput("hello")}); !errors.Is(err, agent.ErrSteeringInactive) { - t.Fatal(err) - } -} - -func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { - emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - emit(bridgeEvent{Type: "delta", Delta: "ready"}) - if mode == "steering-blocked-write" { - time.Sleep(time.Hour) - return - } - if !scanner.Scan() { - os.Exit(2) - } - var input struct { - Type string - Input proto.MessageInput - InputID string `json:"input_id"` - } - if json.Unmarshal(scanner.Bytes(), &input) != nil || input.Type != "steer" || *input.Input[0].Content[0].Text != "additional" || input.InputID != "extra" { - os.Exit(3) - } - if mode == "steering-cancel" { - if scanner.Scan() { - emit(bridgeEvent{Type: "error", Code: "cancelled"}) - } - return - } - if mode == "steering-timeout" { - time.Sleep(100 * time.Millisecond) - } - if mode == "steering-wrong-receipt" { - emit(bridgeEvent{Type: "input_applied", InputID: "unknown"}) - time.Sleep(time.Hour) - return - } - emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: "first", Usage: json.RawMessage(`{"usage":{"input_tokens":4},"modelUsage":{"model":{"inputTokens":4}},"total_cost_usd":0.1}`)}) - emit(bridgeEvent{Type: "input_applied", InputID: input.InputID}) - time.Sleep(30 * time.Millisecond) - id := "second" - if mode == "steering-duplicate-usage" { - id = "first" - } - emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: id, Usage: json.RawMessage(`{"usage":{"input_tokens":7},"modelUsage":{"model":{"inputTokens":11}},"total_cost_usd":0.3}`)}) - emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "final"}) -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go b/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go deleted file mode 100644 index db723328e..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package claudesdk - -import ( - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { - config := workspaceFixture(t) - req := workspaceRequest() - start, _, err := prepare(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) - 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) - limit = 4 - if err != nil || start.Subagents.MaxConcurrent != 2 { - t.Fatal("subagent configuration was not frozen", err) - } -} - -func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { - config := workspaceFixture(t) - for _, change := range []func(*proto.PromptRequestPayload){ - func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, - func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, - func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "function"}} }, - func(r *proto.PromptRequestPayload) { v := []proto.MCPHTTPServer{}; r.MCPHTTPServers = &v }, - } { - req := workspaceRequest() - req.DisableSubagents, req.ObserveSubagentIdentities = false, true - change(&req) - if _, _, err := prepare(config, req); err == nil { - t.Fatal("unqualified subagent combination accepted") - } - } - if (RuntimeInfo{}).SupportsSubagents() || !(RuntimeInfo{Features: []string{"subagent_resources"}}).SupportsSubagents() { - t.Fatal("subagent readiness did not require the packaged feature") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go deleted file mode 100644 index 23db27770..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package claudesdk - -import ( - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestSelfHostedToolEnvironment(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" - file := filepath.Join(t.TempDir(), "tool-env.json") - if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled"} - profile, _, err := prepareWorkspace(config, req) - if err != nil { - t.Fatal(err) - } - if profile.ToolEnv["USER_VALUE"] != "ready" { - t.Fatal("self-hosted tool configuration was not applied") - } - if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { - t.Fatal(err) - } - if _, _, err := prepareWorkspace(config, req); err == nil { - t.Fatal("invalid explicit tool configuration was ignored") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/unsupported.go b/apps/parsar-daemon/internal/agent/claudesdk/unsupported.go deleted file mode 100644 index e6d650711..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/unsupported.go +++ /dev/null @@ -1,28 +0,0 @@ -package claudesdk - -import ( - "context" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (s *session) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { - return fmt.Errorf("%w: native permission responses are not exposed", agent.ErrUnsupportedOperation) -} - -func (s *session) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { - return fmt.Errorf("%w: native user-choice responses are not exposed", agent.ErrUnsupportedOperation) -} - -func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} - -func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/unsupported_test.go b/apps/parsar-daemon/internal/agent/claudesdk/unsupported_test.go deleted file mode 100644 index 8b8f77c88..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/unsupported_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// 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") - } - } - var turn *session - check(turn.SubmitPermission(ctx, secret, proto.PermissionDecisionPayload{})) - check(turn.SubmitPromptForUserChoice(ctx, secret, proto.PromptForUserChoiceDecisionPayload{})) - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil), (*prepared)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/usage.go b/apps/parsar-daemon/internal/agent/claudesdk/usage.go deleted file mode 100644 index 5460e3391..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/usage.go +++ /dev/null @@ -1,37 +0,0 @@ -package claudesdk - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func nativeUsage(raw json.RawMessage) (proto.Usage, error) { - var snapshot map[string]any - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - if err := decoder.Decode(&snapshot); err != nil || snapshot == nil { - return proto.Usage{}, fmt.Errorf("claudesdk: invalid native usage snapshot") - } - // The SDK owns native counter scopes and cost estimates. Do not expose an - // incomplete public token breakdown or a model selected from an unordered map. - return proto.Usage{Provider: "claude_code", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil -} - -// Keep every native measurement when a query spans multiple native turns. Each -// main-loop usage is per native turn; modelUsage and cost are cumulative snapshots. -func appendNativeUsage(previous proto.Usage, raw json.RawMessage) (proto.Usage, error) { - current, err := nativeUsage(raw) - if err != nil || previous.Raw == nil { - return current, err - } - snapshots, ok := previous.Raw["claude_sdk_results"].([]any) - if !ok { - snapshots = []any{previous.Raw["claude_sdk_result"]} - } - retained := append([]any{}, snapshots...) - current.Raw["claude_sdk_results"] = append(retained, current.Raw["claude_sdk_result"]) - return current, nil -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go deleted file mode 100644 index c4167b72e..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ /dev/null @@ -1,151 +0,0 @@ -package claudesdk - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -// WorkspaceConfig binds one trusted private placement. It does not create an -// isolation boundary or authorize a public Environment. The operator must place -// the entire factory inside the qualified outer mount/process boundary first. -// State directories must already exist. -// PublicDirectory may name a second mount of the same workspace inode. -type WorkspaceConfig struct { - Directory string - PublicDirectory string - NetworkAccess string - AllowedDomains []string - HomeDir string - ScratchDir string -} - -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"` - NetworkAccess string `json:"network_access,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` -} - -func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment { - return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") - } - if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { - return nil, nil, fmt.Errorf("claudesdk: work_dir conflicts with the trusted workspace binding") - } - if req.LocalEnvironment != nil && !(agentnetwork.Policy{Access: config.Workspace.NetworkAccess, AllowedDomains: config.Workspace.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) { - return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") - } - profile, env, err := workspaceEnvironment(config) - if err != nil { - return nil, nil, err - } - if req.LocalEnvironment != nil { - profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() - if err != nil { - return nil, nil, err - } - } - - if req.LocalEnvironment != nil { - profile.Skills = req.LocalEnvironment.Skills - profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot - } - servers, credentials, err := prepareRuntimeMCP(req) - if err != nil { - return nil, nil, err - } - profile.MCP = servers - return profile, append(env, credentials...), nil -} - -func workspaceCwd(w *WorkspaceConfig) string { - if w.PublicDirectory != "" { - return w.PublicDirectory - } - return w.Directory -} - -// Harness state paths and selected model credentials overlay the user environment. -func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { - fail := func() (*workspaceProfile, []string, error) { - return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") - } - w := config.Workspace - if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { - return fail() - } - for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { - info, err := os.Stat(path) - if !filepath.IsAbs(path) || err != nil || !info.Mode().IsRegular() { - return fail() - } - } - for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { - if !canonicalWorkspaceDir(path) { - return fail() - } - } - if w.PublicDirectory != "" { - actual, err := os.Stat(w.Directory) - alias, aliasErr := os.Stat(w.PublicDirectory) - if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { - return fail() - } - } - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} - env := []string{} - for _, entry := range os.Environ() { - name, _, _ := strings.Cut(entry, "=") - if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { - env = append(env, entry) - } - } - env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") - seen := map[string]bool{} - for _, entry := range config.Env { - name, _, ok := strings.Cut(entry, "=") - if !ok || seen[name] || strings.ContainsRune(entry, '\x00') || !workspaceEnvName(name) { - return fail() - } - seen[name] = true - profile.EnvNames = append(profile.EnvNames, name) - env = append(env, entry) - } - return profile, env, nil -} - -func workspaceEnvName(name string) bool { - switch name { - case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": - return true - default: - return false - } -} - -func canonicalWorkspaceDir(dir string) bool { - if !workspacePathSyntax(dir) { - return false - } - info, err := os.Stat(dir) - return err == nil && info.IsDir() -} -func workspacePathSyntax(dir string) bool { - return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go deleted file mode 100644 index bc4822ce4..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go +++ /dev/null @@ -1,220 +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-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/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 directoryPreparation(t *testing.T, mode string) (*prepared, Config) { - t.Helper() - config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config -} - -func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") - for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("accepted %q: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { - t.Fatal(limit, err) - } - } - result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) - if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { - t.Fatal(result, err) - } - empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) - if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { - t.Fatal(empty, err) - } - for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { - t.Fatal(path, err) - } - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") - ctx, detach := context.WithCancel(t.Context()) - defer detach() - result := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - detach() - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { - t.Fatal(err) - } - out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - select { - case err := <-result: - t.Fatal("caller detach discarded native wait", err) - 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) { - p, _ := directoryPreparation(t, "directory-normal") - result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) - if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { - t.Fatal(result, err) - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } - }) - } - for _, mode := range []string{"directory-held", "directory-exit"} { - t.Run(mode, func(t *testing.T) { - p, config := directoryPreparation(t, mode) - done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if mode == "directory-held" { - if err := p.Close(); err != nil { - 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) { - p, config := directoryPreparation(t, "directory-held") - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() - waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) - if err := <-done; err != agent.ErrWorkspaceReadUncertain { - t.Fatal(err) - } - if p.session.process.Context().Err() == nil { - t.Fatal("uncertain deadline returned before owner cancellation") - } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go deleted file mode 100644 index 5f0a4052d..000000000 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go +++ /dev/null @@ -1,211 +0,0 @@ -package claudesdk - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "io" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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 = (*prepared)(nil) -var _ agent.WorkspaceReader = (*session)(nil) - -func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return p.session.awaitWorkspaceRead(ctx, read) -} - -func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - if s.owner != nil { - return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) - } - 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/parsar-daemon/internal/agent/codex/contracts.go b/apps/parsar-daemon/internal/agent/codex/contracts.go deleted file mode 100644 index d3fb3e356..000000000 --- a/apps/parsar-daemon/internal/agent/codex/contracts.go +++ /dev/null @@ -1,27 +0,0 @@ -package codex - -import "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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.Steerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.PermissionResponder = (*Session)(nil) - _ agent.UserChoiceResponder = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*Prepared)(nil) - _ agent.PreparedCancellation = (*Prepared)(nil) - _ agent.WorkspaceReader = (*Executor)(nil) - _ agent.WorkspaceDirectoryLister = (*Executor)(nil) - _ agent.WorkspaceWriter = (*Executor)(nil) - _ agent.WorkspaceReader = (*Prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) - _ agent.WorkspaceWriter = (*Prepared)(nil) -) diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go deleted file mode 100644 index 61f85d962..000000000 --- a/apps/parsar-daemon/internal/agent/codex/environment.go +++ /dev/null @@ -1,62 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Check the native provider instead of assuming an older binary honors the flag. -func verifyNoExecutionEnvironment(ctx context.Context, rpc *JSONRPCClient) error { - for _, id := range []string{"local", "remote"} { - status, err := nativeEnvironmentStatus(ctx, rpc, id) - if err != nil { - return fmt.Errorf("codex: cannot confirm disabled execution environment: %w", err) - } - if status != "unknown" { - return fmt.Errorf("codex: execution environment %s was not disabled", id) - } - } - return nil -} - -func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) (string, error) { - raw, err := rpc.Request(ctx, "environment/status", map[string]string{"environmentId": id}) - if err != nil { - return "", err - } - var result struct { - Status string `json:"status"` - } - if json.Unmarshal(raw, &result) != nil || result.Status == "" { - return "", fmt.Errorf("codex: invalid native environment status") - } - return result.Status, nil -} - -// Native still recognizes the retired transport variables. Reject them before -// setup so inherited or operator options cannot select a separate executor. -// The explicit none selector remains part of native execution isolation. -func validateNativeTransportEnvironment(req proto.PromptRequestPayload) error { - options, err := buildSessionEnv(req.AgentOptions) - if err != nil { - return err - } - for _, environment := range [][]string{os.Environ(), options} { - for _, entry := range environment { - key, value, _ := strings.Cut(entry, "=") - if value == "" { - continue - } - if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { - return errors.New("codex: retired executor transport configuration is not supported") - } - } - } - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/error_classification_test.go b/apps/parsar-daemon/internal/agent/codex/error_classification_test.go deleted file mode 100644 index 5dd41790c..000000000 --- a/apps/parsar-daemon/internal/agent/codex/error_classification_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestPinnedTerminalErrorClassification(t *testing.T) { - for value, want := range map[string]string{ - "unauthorized": "authentication_error", "usageLimitExceeded": "usage_limit_exceeded", "rateLimitExceeded": "rate_limit_exceeded", - "contextWindowExceeded": "context_length_exceeded", "serverOverloaded": "server_overloaded", "internalServerError": "server_error", - "badRequest": "invalid_request", "cyberPolicy": "cyber_policy", "sessionBudgetExceeded": "", "misalignmentPolicyViolation": "", - "threadRollbackFailed": "", "sandboxError": "", "other": "", "unknownFuture": "", - } { - raw, _ := json.Marshal(value) - got := classifyTurnError(&TurnError{Message: "unauthorized 429 timeout", CodexErrorInfo: raw}) - if got.Code != want || got.HTTPStatus != nil { - t.Fatalf("%s: %+v", value, got) - } - } - for _, tc := range []struct { - raw, code string - status int - }{ - {`{"httpConnectionFailed":{"httpStatusCode":401}}`, "connection_failed", 401}, - {`{"responseStreamConnectionFailed":{"httpStatusCode":503}}`, "connection_failed", 503}, - {`{"responseStreamDisconnected":{"httpStatusCode":null}}`, "connection_failed", 0}, - {`{"responseTooManyFailedAttempts":{"httpStatusCode":429}}`, "rate_limit_exceeded", 0}, - {`{"responseTooManyFailedAttempts":{"httpStatusCode":502}}`, "connection_failed", 502}, - {`{"httpConnectionFailed":{"httpStatusCode":"401"}}`, "connection_failed", 0}, - {`{"httpConnectionFailed":{"httpStatusCode":65535}}`, "connection_failed", 0}, - {`{"httpConnectionFailed":null}`, "", 0}, {`{"activeTurnNotSteerable":{"turnKind":"review"}}`, "", 0}, - {`{"httpConnectionFailed":{},"other":{}}`, "", 0}, {`null`, "", 0}, - } { - got := classifyTurnError(&TurnError{CodexErrorInfo: json.RawMessage(tc.raw)}) - status := 0 - if got.HTTPStatus != nil { - status = *got.HTTPStatus - } - if got.Code != tc.code || status != tc.status { - t.Fatalf("%s: %+v", tc.raw, got) - } - } -} - -func TestClassificationOnlyFromRootTerminalError(t *testing.T) { - for _, mode := range []string{"failed", "recovered", "notification-only"} { - t.Run(mode, func(t *testing.T) { - out := make(chan proto.Envelope, 16) - s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{})} - s.registerHandlers() - s.setThreadID("root") - scopeNotification(t, s, "turn/started", `{"threadId":"root","turn":{"id":"turn"}}`) - scopeNotification(t, s, "error", `{"threadId":"root","turnId":"turn","error":{"message":"retry","codexErrorInfo":"unauthorized"},"willRetry":true}`) - scopeNotification(t, s, "turn/completed", `{"threadId":"child","turn":{"id":"turn","status":"failed","error":{"message":"child","codexErrorInfo":"usageLimitExceeded"}}}`) - status, errJSON := "failed", `{"message":"terminal","codexErrorInfo":"serverOverloaded"}` - if mode == "recovered" { - status = "completed" - } - if mode == "notification-only" { - errJSON = "null" - } - scopeNotification(t, s, "turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"`+status+`","usage":{"inputTokens":7,"outputTokens":2},"error":`+errJSON+`}}`) - errors, done := 0, false - for env := range out { - if env.Type == proto.TypeError { - errors++ - var p proto.ErrorPayload - _ = env.DecodePayload(&p) - want := "" - if mode == "failed" { - want = "server_overloaded" - } - if p.Code != want { - t.Fatal(p) - } - } - if env.Type == proto.TypeDone { - done = true - var p proto.DonePayload - _ = env.DecodePayload(&p) - if p.Metadata[proto.DoneMetaAgentSessionID] != "root" || p.Usage.InputTokens != 7 { - t.Fatal(p) - } - } - } - if !done || errors != map[bool]int{true: 0, false: 1}[mode == "recovered"] { - t.Fatal("terminal lost", done, errors) - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go b/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go deleted file mode 100644 index f4a21a5a9..000000000 --- a/apps/parsar-daemon/internal/agent/codex/execution_controls_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package codex - -import ( - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestExecutionControlsOverrideWithoutMutatingNativeOptions(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - original := map[string]any{"model": "test-model", "web_search": "live", "model_verbosity": "high"} - request := proto.PromptRequestPayload{AgentOptions: original} - if got := executionOptions(request); !reflect.DeepEqual(got, original) { - t.Fatal("ordinary options changed") - } - for _, search := range []string{"disabled", "cached", "live"} { - for _, verbosity := range []string{"low", "medium", "high"} { - request.ExecutionControls = &proto.ExecutionControls{WebSearch: search, TextVerbosity: verbosity} - options := executionOptions(request) - if options["model"] != "test-model" || original["web_search"] != "live" || original["model_verbosity"] != "high" { - t.Fatal("operator options mutated") - } - plan, err := BuildSessionPlan("run", "state", "", options) - if err != nil { - t.Fatal(err) - } - want := map[string]string{"web_search": `"` + search + `"`, "model_verbosity": `"` + verbosity + `"`} - for _, kv := range plan.ExtraConfig { - if v, ok := want[kv[0]]; ok { - if kv[1] != v { - t.Fatal("native control differs", kv) - } - delete(want, kv[0]) - } - } - plan.Cleanup() - if len(want) != 0 { - t.Fatal("native settings omitted", want) - } - } - } -} - -func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, controls := range []proto.ExecutionControls{ - {}, {WebSearch: "disabled"}, {TextVerbosity: "medium"}, - {WebSearch: "invalid", TextVerbosity: "medium"}, {WebSearch: "disabled", TextVerbosity: "invalid"}, - } { - options := executionOptions(proto.PromptRequestPayload{ExecutionControls: &controls}) - if plan, err := BuildSessionPlan("run", "state", "", options); err == nil { - plan.Cleanup() - t.Fatal("invalid controls accepted", controls) - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go deleted file mode 100644 index 40c47708a..000000000 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ /dev/null @@ -1,176 +0,0 @@ -package codex - -import ( - "context" - "errors" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Executor owns the prepared process, fixed plan and native thread. Each StartTurn -// creates independent receipt, observation, cancellation and output ownership. -type Executor struct { - mu sync.Mutex - prepared *Prepared - active *Session - closed bool - closeMu sync.Mutex -} - -func PrepareExecutor(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - return newExecutor(ctx, req, defaultSessionConfig()) -} - -func newExecutor(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Executor, error) { - p, err := newPreparation(ctx, req, cfg) - if err != nil { - if p != nil { - return &Executor{prepared: p}, err - } - return nil, err - } - p.mu.Lock() - p.started = true - close(p.transferred) - p.mu.Unlock() - return &Executor{prepared: p}, nil -} - -func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if out == nil || strings.TrimSpace(runID) == "" || input.Validate() != nil { - return nil, errors.New("codex: start requires a run identity, input and output") - } - if err := ctx.Err(); err != nil { - return nil, err - } - e.mu.Lock() - base := e.prepared.session - if e.closed || base.cancelCtx.Err() != nil || !base.rpc.Alive() { - e.mu.Unlock() - return nil, errors.New("codex: executor unavailable") - } - previous := e.active - if previous != nil { - select { - case <-previous.waitDone: - default: - e.mu.Unlock() - return nil, errors.New("codex: previous turn has not settled") - } - if previous.settlementErr != nil || !previous.settlement.Reusable { - e.mu.Unlock() - return nil, errors.New("codex: executor requires retirement") - } - } - functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} - turnCtx, cancel := context.WithCancel(base.cancelCtx) - s := &Session{executor: e, nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, - observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, - cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, - waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, - bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, interactions: newPendingCodexInteractions(), runID: runID, out: out} - if previous != nil { - s.threadID = previous.currentThreadID() - s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) - for id := range previous.retiredTurns { - s.retiredTurns[id] = true - } - previous.steering.mu.Lock() - s.retiredTurns[previous.steering.id] = true - previous.steering.mu.Unlock() - s.resolvedModel = previous.resolvedModel - previous.usageMu.Lock() - s.usageTotal = previous.usageTotal - previous.usageMu.Unlock() - } - e.active = s - if s.observeSubagentIdentities { - s.startSubagentObservations() - } - s.registerHandlers() - e.mu.Unlock() - req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, StrictResume: e.prepared.strictResume, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} - // Ownership precedes any native submission. Even an uncertain start returns the - // exact Turn so its caller can await settlement without replaying the input. - err := s.startNative(ctx, e.prepared.plan, req) - if err != nil { - s.emitTerminal("codex: native start failed", true) - s.finishAfterTerminal() - } - go s.settleExecutorTurn(err) - return s, err -} - -func (e *Executor) Close(ctx context.Context) error { - e.mu.Lock() - e.closed = true - e.mu.Unlock() - done := make(chan error, 1) - go func() { - e.closeMu.Lock() - defer e.closeMu.Unlock() - base := e.prepared.session - e.mu.Lock() - running := e.active - e.mu.Unlock() - if running != nil { - select { - case <-running.waitDone: - default: - // Try native cancellation before closing its transport, but never - // let an absent terminal receipt prevent resource retirement. - cancelCtx, stop := context.WithTimeout(ctx, 10*time.Second) - _ = running.Cancel(cancelCtx) - stop() - } - } - if running != nil && base.rpc.Alive() { - // A caller deadline only stops waiting. Cleanup retains a bounded - // opportunity to release native terminals before closing their owner. - cleanupCtx, stop := context.WithTimeout(context.Background(), rpcKillTimeout) - err := running.cleanupNativeTerminals(cleanupCtx) - stop() - if err != nil { - // Preserve the native owner and exact handles for a later Close. - done <- err - return - } - } - base.cancelFn() - err := base.rpc.Close() - e.mu.Lock() - active := e.active - e.mu.Unlock() - if err == nil && active != nil { - // A Turn's permanent outcome error survives Close. Cleanup succeeds - // when its work and output have stopped, not when that outcome changes. - select { - case <-active.waitDone: - case <-ctx.Done(): - err = ctx.Err() - } - } - if err == nil { - err = base.rpc.awaitReaders(ctx) - } - if err == nil { - e.prepared.plan.Cleanup() - } - done <- err - }() - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -var _ agent.Executor = (*Executor)(nil) - -func NewExecutorFactory() agent.ExecutorFactory { return PrepareExecutor } diff --git a/apps/parsar-daemon/internal/agent/codex/executor_native_test.go b/apps/parsar-daemon/internal/agent/codex/executor_native_test.go deleted file mode 100644 index 22d381544..000000000 --- a/apps/parsar-daemon/internal/agent/codex/executor_native_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -// This opt-in test uses the real pinned harness and an explicitly configured -// Responses provider. Credentials are read from a private file, never arguments. -func TestExecutorNativeReuse(t *testing.T) { - 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, "") - } - cfg := defaultSessionConfig() - cfg.codexBinary = binary - cfg.logger = obslog.Discard() - req := proto.PromptRequestPayload{ - AgentKind: "codex", AgentStateKey: "executor-native", WorkDir: filepath.Join(isolated, "workspace"), - StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, - AgentOptions: map[string]any{"model": model, "model_provider": map[string]any{"base_url": endpoint, "protocol": "responses", "api_key": strings.TrimSpace(string(key))}}, - FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, - } - 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/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go deleted file mode 100644 index 69ca2b173..000000000 --- a/apps/parsar-daemon/internal/agent/codex/executor_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package codex - -import ( - "context" - "errors" - "os" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func executorFixture(t *testing.T, mode string) (*Executor, string) { - t.Helper() - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) - ownerCtx, cancelOwner := context.WithCancel(context.Background()) - t.Cleanup(cancelOwner) - e, err := newExecutor(ownerCtx, req, cfg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(ctx); err != nil { - t.Error(err) - } - }) - return e, root -} -func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) agent.TurnSettlement { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - settlement, err := turn.AwaitSettlement(ctx) - if err != nil { - t.Fatal(err) - } - count := 0 - for frame := range out { - if frame.Type == proto.TypeDone { - count++ - } - } - if count != 1 { - t.Fatalf("terminal count %d", count) - } - return settlement -} -func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { - e, root := executorFixture(t, "complete") - var previous agent.Turn - for _, id := range []string{"one", "two"} { - out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) - if err != nil { - t.Fatal(err) - } - if !awaitExecutorTurn(t, turn, out).Reusable { - t.Fatal("healthy turn was not reusable") - } - if previous != nil { - if err := previous.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - } - previous = turn - } - counts := map[string]int{} - pids := map[int]bool{} - for _, f := range preparationFrames(t, root) { - counts[f.Method]++ - pids[f.PID] = true - } - if counts["initialize"] != 1 || counts["thread/start"] != 1 || counts["turn/start"] != 2 || counts["turn/interrupt"] != 0 || len(pids) != 1 { - t.Fatal(counts, pids) - } - if !e.prepared.session.rpc.Alive() { - t.Fatal("normal completion closed executor") - } -} -func TestExecutorCancellationSettlesThenReuses(t *testing.T) { - e, root := executorFixture(t, "complete") - out := make(chan proto.Envelope, 20) - first, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) - if err != nil { - t.Fatal(err) - } - if _, err := e.StartTurn(t.Context(), "overlap", proto.TextInput("answer"), make(chan proto.Envelope, 20)); err == nil { - t.Fatal("overlapping turn admitted") - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - if err := first.Cancel(ctx); err != nil { - t.Fatal(err) - } - if !awaitExecutorTurn(t, first, out).Reusable { - t.Fatal("cancelled healthy executor unavailable") - } - secondOut := make(chan proto.Envelope, 20) - second, err := e.StartTurn(t.Context(), "next", proto.TextInput("hold"), secondOut) - if err != nil { - t.Fatal(err) - } - if err := first.Cancel(ctx); err != nil { - t.Fatal(err) - } - // A stale handle cannot interrupt its successor. - interrupts := 0 - for _, f := range preparationFrames(t, root) { - if f.Method == "turn/interrupt" { - interrupts++ - } - } - if interrupts != 1 { - t.Fatalf("stale cancellation sent %d interrupts", interrupts) - } - if err := second.Cancel(ctx); err != nil { - t.Fatal(err) - } - if !awaitExecutorTurn(t, second, secondOut).Reusable { - t.Fatal("second cancellation did not settle") - } -} -func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { - for _, mode := range []string{"start-error", "disconnect"} { - t.Run(mode, func(t *testing.T) { - e, _ := executorFixture(t, mode) - out := make(chan proto.Envelope, 20) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - turn, err := e.StartTurn(ctx, "before", proto.TextInput("answer"), out) - if turn != nil || !errors.Is(err, context.Canceled) { - t.Fatal(turn, err) - } - select { - case <-out: - t.Fatal("pre-admission output was retained or closed") - default: - } - turn, err = e.StartTurn(t.Context(), "after", proto.TextInput("answer"), out) - if turn == nil || err == nil { - t.Fatal("uncertain submission lost owner", err) - } - settlement, settleErr := turn.AwaitSettlement(t.Context()) - if settleErr == nil || settlement.Reusable { - t.Fatal("failed submission fabricated native settlement", settlement, settleErr) - } - terminalCount := 0 - for frame := range out { - if frame.Type == proto.TypeDone { - terminalCount++ - } - } - if terminalCount != 1 { - t.Fatalf("failed submission terminal count = %d", terminalCount) - } - }) - } -} - -func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) - if err != nil { - t.Fatal(err) - } - rpc := NewJSONRPCClient(JSONRPCConfig{}) - rpc.process, rpc.cmd, rpc.stdin, rpc.alive = process, process.Cmd, process.Stdin, true - - var reap sync.Once - t.Cleanup(func() { process.Cancel(); reap.Do(rpc.waitChild) }) - _, cancel := context.WithCancel(t.Context()) - var cleaned atomic.Bool - e := &Executor{prepared: &Prepared{session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cleanup: func() { cleaned.Store(true) }}}} - ctx, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer stop() - if err = e.Close(ctx); !errors.Is(err, context.DeadlineExceeded) { - t.Fatal("unreaped close", err) - } - if cleaned.Load() { - t.Fatal("plan released before cleanup settled") - } - reap.Do(rpc.waitChild) - if err = e.Close(t.Context()); err != nil { - t.Fatal(err) - } - if !cleaned.Load() { - t.Fatal("cleanup retry did not release plan") - } -} - -func TestExecutorCloseSeparatesTurnFailureFromResourceCleanup(t *testing.T) { - e, root := executorFixture(t, "interrupt-error") - out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "cancel-failure", proto.TextInput("hold"), out) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - if err := turn.Cancel(ctx); err == nil { - t.Fatal("native interrupt failure was hidden") - } - if _, err := turn.AwaitSettlement(ctx); err == nil { - t.Fatal("failed Turn unexpectedly settled successfully") - } - if err := e.Close(ctx); err != nil { - t.Fatal("failed Turn prevented confirmed resource cleanup", err) - } - if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { - t.Fatal("Close did not release native process and plan") - } - if _, err := turn.AwaitSettlement(ctx); err == nil { - t.Fatal("resource cleanup fabricated successful Turn settlement") - } -} - -func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { - e, root := executorFixture(t, "interrupt-no-terminal") - out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), "missing-terminal", proto.TextInput("hold"), out) - if err != nil { - t.Fatal(err) - } - cancelCtx, stopCancel := context.WithTimeout(t.Context(), 5*time.Second) - defer stopCancel() - cancelled := make(chan error, 1) - go func() { cancelled <- turn.Cancel(cancelCtx) }() - waitPreparationMethod(t, root, "turn/interrupt") - closeCtx, stopClose := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer stopClose() - if err := e.Close(closeCtx); !errors.Is(err, context.DeadlineExceeded) { - t.Fatal("missing receipt did not reach the cleanup deadline", err) - } - // Retrying waits on the same owner; the expired observation must not skip - // the transport close that can actually stop an unresponsive native Turn. - retry, stopRetry := context.WithTimeout(t.Context(), 2*time.Second) - defer stopRetry() - if err := e.Close(retry); err != nil { - t.Fatal("Close never retired the native process", err) - } - if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { - t.Fatal("unresponsive native process or plan still owned after Close") - } - select { - case cancelErr := <-cancelled: - if cancelErr == nil { - t.Fatal("resource cleanup fabricated native cancellation confirmation") - } - case <-retry.Done(): - t.Fatal("original cancellation waiter was abandoned") - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/executor_turn.go b/apps/parsar-daemon/internal/agent/codex/executor_turn.go deleted file mode 100644 index 0579e81fa..000000000 --- a/apps/parsar-daemon/internal/agent/codex/executor_turn.go +++ /dev/null @@ -1,175 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" -) - -func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - select { - case <-s.waitDone: - return s.settlement, s.settlementErr - case <-ctx.Done(): - return agent.TurnSettlement{}, ctx.Err() - } -} - -func (s *Session) settleExecutorTurn(startErr error) { - defer close(s.waitDone) - if startErr == nil { - select { - case <-s.outputDone: - case <-s.rpc.Done(): - s.emitTerminal("codex: connection closed before settlement", true) - case <-s.cancelCtx.Done(): - s.emitTerminal("codex: execution owner closed", true) - } - } - // Detach the old callbacks before waiting for their captured Turn and native - // replies. Later frames cannot acquire this Turn or redirect to a successor. - if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { - s.cancelFn() - s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) - } - if !s.nativeSettled.Load() { - s.settlementErr = errors.Join(s.settlementErr, errors.New("codex: native Turn settlement is unconfirmed")) - } - s.rpc.detachHandlers() - s.operationMu.Lock() - s.operationsClosed = true - s.operationMu.Unlock() - s.stopSteering() - s.stopFunctionCalls() - s.stopCodexInteractionTimers() - if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { - s.cancelFn() - s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) - s.settlement = agent.TurnSettlement{Reason: "native_execution_unavailable"} - } else { - s.settlement = agent.TurnSettlement{Reusable: true} - } - s.closeRunOutput() - s.operations.Wait() - if s.subagents != nil { - s.subagents.mu.Lock() - err := s.subagents.cancelResult - s.subagents.mu.Unlock() - if err != nil { - s.settlement = agent.TurnSettlement{Reason: "child_settlement_unconfirmed"} - s.settlementErr = errors.Join(s.settlementErr, err, s.rpc.Close()) - } - } - // The terminal callback has returned before detachHandlers completes. A - // concurrent cancellation owns only this Turn and finishes before reuse. - s.operationMu.Lock() - ready := s.cancelReady - s.operationMu.Unlock() - if ready != nil { - <-ready - if s.cancelErr != nil { - s.settlement.Reusable = false - s.settlement.Reason = "cancellation_unconfirmed" - s.settlementErr = errors.Join(s.settlementErr, s.cancelErr, s.rpc.Close()) - } - } - if s.cancelled.Load() && s.nativeSettled.Load() && s.cancelErr == nil && s.rpc.Alive() { - cleanupCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) - err := s.cleanupNativeTerminals(cleanupCtx) - stop() - if err != nil { - s.settlement = agent.TurnSettlement{Reason: "native_terminal_cleanup_unconfirmed"} - s.settlementErr = errors.Join(s.settlementErr, err) - } - } - s.cancelFn() -} - -func (s *Session) beginOperation() bool { - if s.executor == nil { - return true - } - s.operationMu.Lock() - defer s.operationMu.Unlock() - if s.operationsClosed { - return false - } - s.operations.Add(1) - return true -} -func (s *Session) endOperation() { - if s.executor != nil { - s.operations.Done() - } -} - -func (s *Session) cancelExecutorTurn(ctx context.Context) error { - s.operationMu.Lock() - if s.operationsClosed { - s.operationMu.Unlock() - _, err := s.AwaitSettlement(ctx) - return err - } - s.cancelOnce.Do(func() { - s.cancelled.Store(true) - s.cancelReady = make(chan struct{}) - go func() { - defer close(s.cancelReady) - s.stopFunctionCalls() - turnID, active := s.stopSteering() - s.stopCodexInteractionTimers() - if !s.terminal.Load() && active { - if turnID == "" { - s.cancelErr = errors.New("codex: cancellation has no native turn identity") - s.cancelFn() - _ = s.rpc.Close() - return - } - interruptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _, err := s.rpc.request(interruptCtx, "turn/interrupt", TurnInterruptParams{ThreadID: s.currentThreadID(), TurnID: turnID}, func(frame any) error { return s.rpc.writeFrameContext(interruptCtx, frame) }) - cancel() - if err != nil { - s.cancelErr = err - s.cancelFn() - _ = s.rpc.Close() - return - } - } - if s.subagents != nil { - s.cancelErr = s.cancelSubagentWork(s.cancelCtx) - } - }() - }) - s.operationMu.Unlock() - _, err := s.AwaitSettlement(ctx) - return err -} - -var _ agent.Turn = (*Session)(nil) - -// Server callbacks retain their originating Turn. MCP elicitation may be a -// standalone thread-scoped request in the native protocol; a supplied Turn ID -// must still match exactly. -func (s *Session) onServerRequest(method string, handler ServerRequestHandler) { - s.rpc.OnServerRequest(method, func(raw json.RawMessage, id any) (any, error) { - if s.executor != nil { - var scope struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - } - if json.Unmarshal(raw, &scope) != nil || !s.isRootThread(scope.ThreadID) || s.terminal.Load() || - (scope.TurnID != "" && !s.isRootTurn(scope.ThreadID, scope.TurnID)) || - (scope.TurnID == "" && method != "mcpServer/elicitation/request") { - return nil, errors.New("codex: request does not belong to the active turn") - } - } - if !s.beginOperation() { - return nil, errors.New("codex: turn has settled") - } - defer s.endOperation() - return handler(raw, id) - }) -} diff --git a/apps/parsar-daemon/internal/agent/codex/functions.go b/apps/parsar-daemon/internal/agent/codex/functions.go deleted file mode 100644 index 8b76e8481..000000000 --- a/apps/parsar-daemon/internal/agent/codex/functions.go +++ /dev/null @@ -1,114 +0,0 @@ -package codex - -import ( - "encoding/json" - "errors" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type dynamicFunctionTool struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - InputSchema json.RawMessage `json:"inputSchema"` -} - -type functionCalls struct { - mu sync.Mutex - definitions []dynamicFunctionTool - names map[string]bool - pending map[string]*pendingFunction - closed bool -} - -func prepareFunctionTools(tools []proto.FunctionTool) (*functionCalls, error) { - state := &functionCalls{names: map[string]bool{}, pending: map[string]*pendingFunction{}} - if len(tools) > 64 { - return nil, errors.New("at most 64 function tools are supported") - } - for _, tool := range tools { - var schema map[string]any - if strings.TrimSpace(tool.Name) == "" || state.names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { - return nil, errors.New("function tools require unique names and object schemas") - } - state.names[tool.Name] = true - state.definitions = append(state.definitions, dynamicFunctionTool{Type: "function", Name: tool.Name, Description: tool.Description, InputSchema: append(json.RawMessage(nil), tool.Parameters...)}) - } - return state, nil -} - -func (s *Session) handleFunctionCall(raw json.RawMessage, rpcID any) (any, error) { - var call struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - CallID string `json:"callId"` - Namespace *string `json:"namespace"` - Tool string `json:"tool"` - Arguments json.RawMessage `json:"arguments"` - } - if err := json.Unmarshal(raw, &call); err != nil { - return nil, err - } - if s.functions == nil || !s.functions.names[call.Tool] || call.Namespace != nil || call.CallID == "" || !s.isRootTurn(call.ThreadID, call.TurnID) || !json.Valid(call.Arguments) { - return nil, errors.New("unexpected function call") - } - s.functions.mu.Lock() - if s.functions.closed || s.cancelled.Load() || s.terminal.Load() || s.functions.pending[call.CallID] != nil || len(s.functions.pending) >= 64 { - s.functions.mu.Unlock() - return nil, errors.New("function call cannot be admitted") - } - s.functions.pending[call.CallID] = &pendingFunction{rpcID: rpcID, turnID: call.TurnID, name: call.Tool, receipt: make(chan error, 1)} - s.functions.mu.Unlock() - env, err := proto.NewEnvelope(proto.TypeFunctionCall, s.runID, proto.FunctionCallPayload{CallID: call.CallID, Name: call.Tool, Arguments: call.Arguments}) - if err == nil { - err = s.sendFunctionCall(env) - } - if err != nil { - s.functions.mu.Lock() - delete(s.functions.pending, call.CallID) - s.functions.mu.Unlock() - return nil, err - } - return DeferReply, nil -} - -func (s *Session) sendFunctionCall(env proto.Envelope) error { - s.outMu.RLock() - defer s.outMu.RUnlock() - if s.outClosed { - return agent.ErrUnknownFunctionCall - } - timer := time.NewTimer(terminalSendTimeout) - defer timer.Stop() - select { - case s.out <- env: - return nil - case <-s.cancelCtx.Done(): - return s.cancelCtx.Err() - case <-timer.C: - return errors.New("function call delivery timed out") - } -} - -type functionContent struct { - Type string `json:"type"` - Text *string `json:"text,omitempty"` - ImageURL *string `json:"imageUrl,omitempty"` -} - -func (s *Session) stopFunctionCalls() { - if s.functions != nil { - s.functions.mu.Lock() - s.functions.closed = true - for _, pending := range s.functions.pending { - pending.receipt <- agent.ErrUnknownFunctionCall - } - clear(s.functions.pending) - s.functions.mu.Unlock() - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/functions_test.go b/apps/parsar-daemon/internal/agent/codex/functions_test.go deleted file mode 100644 index d42af8edd..000000000 --- a/apps/parsar-daemon/internal/agent/codex/functions_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestFunctionCallWaitsAndRepliesOnce(t *testing.T) { - for _, success := range []bool{true, false} { - t.Run(map[bool]string{true: "success", false: "failure"}[success], func(t *testing.T) { - tc, srv, cleanup := NewTestClient() - defer cleanup() - s, out := newInteractionTestSession(tc.JSONRPCClient) - var err error - s.functions, err = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}}) - if err != nil { - t.Fatal(err) - } - s.setThreadID("thread") - s.startSteering("thread", "turn") - params := map[string]any{"threadId": "thread", "turnId": "turn", "callId": "call", "tool": "lookup", "arguments": map[string]string{"ticket": "42"}} - if err := SendServerRequest(srv, "rpc-call", "item/tool/call", params); err != nil { - t.Fatal(err) - } - select { - case env := <-out: - var call proto.FunctionCallPayload - if err := env.DecodePayload(&call); err != nil || env.Type != proto.TypeFunctionCall || env.ID != "run-test" || call.CallID != "call" || call.Name != "lookup" { - t.Fatal(env, err) - } - case <-time.After(time.Second): - t.Fatal("missing function call") - } - text, image, empty := "answer", "https://example.com/result.png", "" - content := []proto.InputContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &image}, {Type: "input_text", Text: &empty}} - if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Content: []proto.InputContent{{Type: "input_audio"}}}); err == nil { - t.Fatal("invalid result consumed the pending call") - } - finished := make(chan error, 1) - go func() { - finished <- s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Success: success, Content: content}) - }() - var reply struct { - ID string `json:"id"` - Result json.RawMessage `json:"result"` - } - if err := json.NewDecoder(srv.FromClient).Decode(&reply); err != nil { - t.Fatal(err) - } - if reply.ID != "rpc-call" { - t.Fatal(reply.ID) - } - status := "completed" - if !success { - status = "failed" - } - var observed map[string]any - if err := json.Unmarshal(reply.Result, &observed); err != nil { - t.Fatal(err) - } - observed["type"], observed["id"], observed["tool"], observed["status"] = "dynamicToolCall", "call", "lookup", status - native, _ := json.Marshal(map[string]any{"threadId": "thread", "turnId": "turn", "item": observed}) - s.onItemCompleted(native) - if err := <-finished; err != nil { - t.Fatal(err) - } - var result struct { - Success bool `json:"success"` - Content []functionContent `json:"contentItems"` - } - if err := json.Unmarshal(reply.Result, &result); err != nil || result.Success != success || !reflect.DeepEqual(result.Content, []functionContent{{Type: "inputText", Text: &text}, {Type: "inputImage", ImageURL: &image}, {Type: "inputText", Text: &empty}}) { - t.Fatal(string(reply.Result), err) - } - if err := s.SubmitFunctionResult(t.Context(), proto.FunctionResultPayload{CallID: "call", Content: content}); !errors.Is(err, agent.ErrUnknownFunctionCall) { - t.Fatal(err) - } - }) - } -} - -func TestFunctionCallRejectsUnregisteredAndClosedRuns(t *testing.T) { - tc, _, cleanup := NewTestClient() - defer cleanup() - s, _ := newInteractionTestSession(tc.JSONRPCClient) - s.setThreadID("thread") - s.startSteering("thread", "turn") - s.functions, _ = prepareFunctionTools([]proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}) - for _, params := range []string{ - `{"threadId":"other","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`, - `{"threadId":"thread","turnId":"turn","callId":"a","tool":"unknown","arguments":{}}`, - `{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","namespace":"foreign","arguments":{}}`, - } { - if _, err := s.handleFunctionCall(json.RawMessage(params), "rpc"); err == nil { - t.Fatal("unexpected call accepted", params) - } - } - s.stopFunctionCalls() - if _, err := s.handleFunctionCall(json.RawMessage(`{"threadId":"thread","turnId":"turn","callId":"a","tool":"lookup","arguments":{}}`), "rpc"); err == nil { - t.Fatal("closed run accepted call") - } - if err := s.SubmitFunctionResult(context.Background(), proto.FunctionResultPayload{CallID: "a", Content: []proto.InputContent{}}); !errors.Is(err, agent.ErrUnknownFunctionCall) { - t.Fatal(err) - } -} - -func TestFunctionToolDefinitionsRejectAmbiguousInput(t *testing.T) { - for _, tools := range [][]proto.FunctionTool{ - {{Name: "", Parameters: json.RawMessage(`{}`)}}, - {{Name: "lookup", Parameters: json.RawMessage(`[]`)}}, - {{Name: "lookup", Parameters: json.RawMessage(`null`)}}, - {{Name: "lookup", Parameters: json.RawMessage(`{}`)}, {Name: "lookup", Parameters: json.RawMessage(`{}`)}}, - } { - if _, err := prepareFunctionTools(tools); err == nil { - t.Fatal("invalid function accepted", tools) - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/harness_config_test.go b/apps/parsar-daemon/internal/agent/codex/harness_config_test.go deleted file mode 100644 index 42574bd3e..000000000 --- a/apps/parsar-daemon/internal/agent/codex/harness_config_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "slices" - "testing" -) - -func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, err := BuildSessionPlan("run", "native-config", "", map[string]any{ - "model": "fixture", "harness_config": map[string]any{"model_reasoning_effort": "high"}, - "model_provider": map[string]any{"base_url": "https://provider.invalid/v1", "protocol": "responses", "api_key": "test-key"}, - }) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if plan.Model != "fixture" || plan.ModelProvider != oacProviderSlug || !slices.Contains(plan.ExtraConfig, [2]string{"model_reasoning_effort", `"high"`}) { - t.Fatalf("native configuration not applied: %+v", plan.ExtraConfig) - } -} - -func TestHarnessConfigConflictFailsBeforePreparation(t *testing.T) { - _, err := BuildSessionPlan("run", "", "", map[string]any{"harness_config": map[string]any{"model_provider": "bypass"}}) - if err != harnessconfig.ErrHarnessConfig { - t.Fatalf("configuration must fail before filesystem preparation: %v", err) - } -} - -func TestHarnessConfigReachesEveryNativeTurn(t *testing.T) { - for _, resumeID := range []string{"", "fixture-native-thread"} { - t.Run("resume="+resumeID, func(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") - req.AgentSessionID = resumeID - native := map[string]any{"model_reasoning_effort": "high"} - req.AgentOptions["harness_config"] = native - ownerCtx, cancelOwner := context.WithCancel(context.Background()) - t.Cleanup(cancelOwner) - e, err := newExecutor(ownerCtx, req, cfg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(ctx); err != nil { - t.Error(err) - } - }) - native["model_reasoning_effort"] = "low" - for _, id := range []string{"one", "two"} { - out := make(chan proto.Envelope, 20) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) - if err != nil { - t.Fatal(err) - } - if !awaitExecutorTurn(t, turn, out).Reusable { - t.Fatal("healthy Turn was not reusable") - } - } - counts := map[string]int{} - for _, frame := range preparationFrames(t, root) { - counts[frame.Method]++ - if frame.Method != "turn/start" { - continue - } - var wire struct { - CollaborationMode struct { - Settings map[string]any `json:"settings"` - } `json:"collaborationMode"` - } - if json.Unmarshal(frame.Params, &wire) != nil || wire.CollaborationMode.Settings["reasoning_effort"] != "high" { - t.Fatal("Turn lost the frozen native reasoning effort", string(frame.Params)) - } - } - method := "thread/start" - if resumeID != "" { - method = "thread/resume" - } - if counts[method] != 1 || counts["turn/start"] != 2 { - t.Fatal("expected one native thread and two Turns", counts) - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/installation.go b/apps/parsar-daemon/internal/agent/codex/installation.go deleted file mode 100644 index 3153759c9..000000000 --- a/apps/parsar-daemon/internal/agent/codex/installation.go +++ /dev/null @@ -1,31 +0,0 @@ -package codex - -import ( - "context" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - "path/filepath" - "runtime" -) - -func Installation() agent.Installation { - binary := func(dir string) string { - name := "codex" - if runtime.GOOS == "windows" { - name += ".exe" - } - 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" }, - Environment: func(dir, node string) map[string]string { - return map[string]string{"OAC_RUNTIME_CODEX_BIN": 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" { - return fmt.Errorf("Codex installation is incompatible") - } - return nil - }} -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go deleted file mode 100644 index 3c8327f26..000000000 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package codex - -import ( - "encoding/json" - "os" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" -) - -func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { - token := "user-token" - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, - }} - servers, env, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}) - if err != nil || len(servers) != 2 || len(env) != 2 { - t.Fatal("environment declarations were not projected", err) - } - executable, _ := os.Executable() - if servers["local"].Command != executable || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { - t.Fatal("native stdio bypasses the packaged launcher") - } - remote := servers["remote"] - if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || - !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || - !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { - t.Fatal("private header values changed") - } - fixture := map[string]any{"config": map[string]any{ - "mcp_oauth_credentials_store": "file", "features": map[string]bool{"plugins": false, "apps": false}, - "mcp_servers": map[string]any{ - "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, - "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve", "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, - }, - }} - raw, _ := json.Marshal(fixture) - if !matchesMCPConfig(raw, servers) { - t.Fatal("qualified native projection rejected") - } - corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) - if matchesMCPConfig(json.RawMessage(corrupt), servers) { - t.Fatal("different native launcher accepted") - } -} - -func TestEnvironmentMCPRejectsUnqualifiedNetworkAndCredentialChanges(t *testing.T) { - for _, access := range []string{"", "restricted", "disabled"} { - local := &proto.LocalEnvironment{NetworkAccess: access, MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "local", Type: "stdio"}}}} - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { - t.Errorf("unqualified MCP network accepted: %s", access) - } - } - token := "user-token" - local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}); err == nil { - t.Fatal("plaintext bearer accepted") - } - local.MCP[0].Server.URL = "https://example.com/mcp" - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp"}}}); err == nil { - t.Fatal("service and environment identity collision accepted") - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http.go b/apps/parsar-daemon/internal/agent/codex/mcp_http.go deleted file mode 100644 index 7a17a3634..000000000 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http.go +++ /dev/null @@ -1,83 +0,0 @@ -package codex - -import ( - "crypto/rand" - "errors" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// One projection consumes both public and installed Runtime bindings. A non-nil -// empty public declaration still owns the complete native MCP configuration. -func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConfig, []string, error) { - bindings, err := agent.ResolveMCPBindings(req) - if err != nil { - return nil, nil, err - } - if bindings == nil { - return nil, nil, nil - } - servers := make(map[string]mcpServerConfig, len(bindings)) - var env []string - for _, binding := range bindings { - if binding.ServerLabel == "codex_apps" { - return nil, nil, errors.New("codex: reserved MCP server label") - } - server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} - if binding.Stdio != nil { - server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) - } - if binding.BearerToken != nil { - server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() - env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) - } - if len(binding.HTTPHeaders) > 0 { - server.EnvHTTPHeaders = map[string]string{} - for name, value := range binding.HTTPHeaders { - reference := "OAC_RUNTIME_MCP_HEADER_" + rand.Text() - server.EnvHTTPHeaders[name] = reference - env = append(env, reference+"="+value) - } - } - servers[server.Name] = server - } - return servers, env, nil -} - -func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { - var codexHome string - for _, entry := range plan.Env { - if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { - codexHome = value - } - } - if codexHome == "" || !filepath.IsAbs(codexHome) { - return errors.New("codex: public MCP requires a private native home") - } - // Native OAuth defaults to the global keyring. File mode confines lookup to - // this owned history directory; never delete existing credentials to admit it. - if _, err := os.Lstat(filepath.Join(codexHome, ".credentials.json")); !errors.Is(err, os.ErrNotExist) { - return errors.New("codex: public MCP requires a native home without stored MCP credentials") - } - if err := writeCodexMCPConfig(codexHome, servers); err != nil { - return errors.New("codex: cannot write public MCP configuration") - } - if plan.Cwd == "" { - plan.Cwd = codexHome - } - for _, feature := range []string{"plugins", "apps"} { - plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) - if !slices.Contains(plan.DisableFeatures, feature) { - plan.DisableFeatures = append(plan.DisableFeatures, feature) - } - } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) - plan.mcpServers = servers - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go deleted file mode 100644 index 22c6a7854..000000000 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package codex - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - tools := []string{"lookup.docs", `quote"tool`} - denyAll := []string{} - servers := []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, - {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, - } - original := map[string]any{ - "mcp_servers": map[string]any{"operator": map[string]any{"command": "operator-mcp"}}, - "enable_features": []any{"apps", "plugins", "unrelated"}, - } - req := proto.PromptRequestPayload{AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: original} - req.AgentOptions = executionOptions(req) - plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if original["mcp_servers"] == nil || req.AgentOptions["mcp_servers"] != nil { - t.Fatal("declaration failed to replace operator MCP without mutation") - } - if !slices.Equal(plan.EnableFeatures, []string{"unrelated"}) || !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { - t.Fatal("native profile was not pinned") - } - home, err := allocCodexHome(req.AgentStateKey) - if err != nil { - t.Fatal(err) - } - if plan.Cwd != home { - t.Fatal("empty cwd did not resolve to the private home") - } - config, err := os.ReadFile(filepath.Join(home, "config.toml")) - if err != nil { - t.Fatal(err) - } - for _, expected := range []string{`[mcp_servers."docs.server"]`, `enabled_tools = ["lookup.docs", "quote\"tool"]`, `enabled_tools = []`, "required = true"} { - if !strings.Contains(string(config), expected) { - t.Fatalf("missing native config %q", expected) - } - } - if strings.Contains(string(config), "operator") { - t.Fatal("operator MCP was rendered") - } - tools[0] = "mutated" - if (*plan.mcpServers["docs.server"].EnabledTools)[0] != "lookup.docs" { - t.Fatal("prepared allowlist retained caller-owned memory") - } - history := filepath.Join(home, "retained-history.jsonl") - if err := os.WriteFile(history, []byte("native-history"), 0o600); err != nil { - t.Fatal(err) - } - servers = []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} - second, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer second.Cleanup() - config, err = os.ReadFile(filepath.Join(home, "config.toml")) - if err != nil || strings.Contains(string(config), "docs.server") || !strings.Contains(string(config), "replacement") || strings.Contains(string(config), "enabled_tools") || strings.Contains(string(config), "required") { - t.Fatal("cold configuration retained old servers or changed unrestricted tools", err) - } - retained, err := os.ReadFile(history) - if err != nil || string(retained) != "native-history" { - t.Fatal("configuration reset changed native history", err) - } -} - -func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { - valid := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} - for _, req := range []proto.PromptRequestPayload{ - {MCPHTTPServers: &valid}, - } { - if _, _, err := runtimeMCPServers(req); err == nil { - t.Fatal("non-service profile accepted") - } - } - for _, server := range []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, - } { - servers := []proto.MCPHTTPServer{server} - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { - t.Fatal("unsupported configuration was accepted or exposed", err) - } - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - home, err := allocCodexHome("credentials") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(home, ".credentials.json") - stored := []byte(`{"synthetic":"private"}`) - if err := os.WriteFile(path, stored, 0o600); err != nil { - t.Fatal(err) - } - req := proto.PromptRequestPayload{AgentStateKey: "credentials", DisableExecutionEnvironment: true, MCPHTTPServers: &valid} - if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { - t.Fatal("existing MCP credentials accepted") - } - if after, err := os.ReadFile(path); err != nil || !reflect.DeepEqual(after, stored) { - t.Fatal("existing credentials were modified", err) - } -} - -// This is the pinned native serializer's config/read shape, not live discovery. -func mcpHTTPConfigResponse(servers map[string]mcpServerConfig) map[string]any { - entries := make(map[string]any, len(servers)) - for name, server := range servers { - entry := map[string]any{"url": server.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "required": server.Required} - if server.EnabledTools != nil { - entry["enabled_tools"] = append([]string{}, (*server.EnabledTools)...) - } - if server.BearerTokenEnvVar != "" { - entry["bearer_token_env_var"] = server.BearerTokenEnvVar - } - entries[name] = entry - } - return map[string]any{"config": map[string]any{"mcp_servers": entries, "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}} -} - -func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { - t.Helper() - data, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } -} - -func TestPublicMCPBearerRequiresHTTPS(t *testing.T) { - req := proto.PromptRequestPayload{DisableExecutionEnvironment: true} - token := "synthetic-private-token" - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} - req.MCPHTTPServers = &servers - if _, _, err := runtimeMCPServers(req); err == nil || strings.Contains(err.Error(), token) { - t.Fatal("plaintext bearer accepted or exposed") - } - servers[0].ServerURL = "https://tools.example/mcp" - if _, _, err := runtimeMCPServers(req); err != nil { - t.Fatal("HTTPS bearer declaration rejected", err) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/message_input.go b/apps/parsar-daemon/internal/agent/codex/message_input.go deleted file mode 100644 index f8bc41a18..000000000 --- a/apps/parsar-daemon/internal/agent/codex/message_input.go +++ /dev/null @@ -1,25 +0,0 @@ -package codex - -import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - -// nativeInput keeps content order. Codex accepts a flat input list, so message -// boundaries use the same blank-line separator as the existing text path. -func nativeInput(messages proto.MessageInput) ([]UserInput, error) { - if err := messages.Validate(); err != nil { - return nil, err - } - var input []UserInput - for i, message := range messages { - if i > 0 { - input = append(input, UserInput{Type: UserInputText, Text: "\n\n"}) - } - for _, part := range message.Content { - if part.Type == "input_text" { - input = append(input, UserInput{Type: UserInputText, Text: *part.Text}) - } else { - input = append(input, UserInput{Type: UserInputRemoteImg, URL: *part.ImageURL}) - } - } - } - return input, nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/message_input_test.go b/apps/parsar-daemon/internal/agent/codex/message_input_test.go deleted file mode 100644 index 6438506c6..000000000 --- a/apps/parsar-daemon/internal/agent/codex/message_input_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package codex - -import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "testing" -) - -func TestNativeInputRetainsImageOrderAndMessageSeparator(t *testing.T) { - image := "data:image/png;base64,aW1hZ2U=" - messages := proto.TextInput(" before ") - messages[0].Content = append(messages[0].Content, proto.InputContent{Type: "input_image", ImageURL: &image}, proto.TextInput(" after ")[0].Content[0]) - messages = append(messages, proto.TextInput("next")...) - input, err := nativeInput(messages) - if err != nil { - t.Fatal(err) - } - if len(input) != 5 || input[0].Text != " before " || input[1].Type != UserInputRemoteImg || input[1].URL != image || input[2].Text != " after " || input[3].Text != "\n\n" || input[4].Text != "next" { - t.Fatalf("native input changed: %+v", input) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go deleted file mode 100644 index 8d41d1302..000000000 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ /dev/null @@ -1,450 +0,0 @@ -package codex - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" - harnessconfiguration "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/codex" -) - -// SessionPlan holds the resolved per-prompt launch plan derived from -// the daemon's PromptRequestPayload. -type SessionPlan struct { - // Cwd is the validated working directory passed to codex (and to - // the spawned app-server). Empty when the caller provided no work_dir. - Cwd string - - // Env is the full environment slice (KEY=value) to layer onto - // os.Environ() before spawning. Includes CODEX_HOME, plus any - // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. - Env []string - - // ExtraConfig is a list of `-c key=value` overrides applied at the - // app-server CLI. Used to layer model_reasoning_summary etc. without - // editing config.toml. - ExtraConfig [][2]string - - // EnableFeatures / DisableFeatures forward to `--enable / --disable` - // flags. Today empty by default; reserved for future ARC opt-in. - EnableFeatures []string - DisableFeatures []string - - // Non-nil for declared service or Environment MCP, including private references. - mcpServers map[string]mcpServerConfig - - // Model is the slug to request on thread/start. Empty inherits the - // codex.config.toml default. - Model string - - // ModelProvider is the slug pinned on thread/start so codex routes - // the prompt through the [model_providers.] entry we wrote - // into /config.toml. Empty leaves codex on its builtin - // "openai" provider (only valid when the caller really wants - // public api.openai.com + OPENAI_API_KEY env), so the normal path is - // oacProviderSlug. - ModelProvider string - - // SystemPrompt is forwarded as developerInstructions on thread/start. - SystemPrompt string - - // CollaborationMode selects Codex's default or plan tool surface. - CollaborationMode CollaborationModeKind - - // ModelReasoningEffort is frozen for launch and every native Turn. - ModelReasoningEffort string - - // ApprovalPolicy + Sandbox apply to both new and resumed threads. - ApprovalPolicy AskForApproval - Sandbox SandboxMode - Permissions string - - // Cleanup is the deferred housekeeping the session must run after the child exits. - Cleanup func() -} - -// BuildSessionPlan derives a SessionPlan from PromptRequestPayload's -// fields. The work_dir / opts shape mirrors how claudecode + opencode -// consume their own opts: a string-keyed map of any. -// -// The agent_options keys this function reads: -// -// model string codex model slug, e.g. "gpt-5.5" -// system_prompt string forwarded as developerInstructions -// override_system_prompt string replaces system_prompt entirely when -// non-empty (mirrors claudecode/opencode) -// env map[string]any extra env vars (KEY=string-value) -// mcp_servers map[string]any rendered MCP server config — written -// to /config.toml [mcp_servers] -// model_provider object frozen upstream protocol, endpoint, credential and token limits -// reasoning_summary string one of auto/concise/detailed/none — -// routed via -c model_reasoning_summary -// mode string Codex collaboration mode: default/plan -// enable_features []any string list, forwarded as --enable -// disable_features []any string list, forwarded as --disable -// -// The codex binary itself is resolved via PATH only — there's no -// per-prompt override knob. If a deployment needs a custom binary -// location, set it via the daemon's process environment (PATH / -// codexBinary in sessionConfig) rather than per-call. -// -// Daemon-managed Codex sessions bypass approvals and the engine sandbox. -func BuildSessionPlan(runID, agentStateKey, workDir string, opts map[string]any) (SessionPlan, error) { - cleanup := func() {} - plan := SessionPlan{ - CollaborationMode: CollaborationModeDefault, - ApprovalPolicy: AskForApproval{String: "never"}, - Sandbox: SandboxDangerFullAcces, - Cleanup: cleanup, - } - - nativeConfig, err := harnessconfiguration.Configuration().PrepareHarnessConfig(opts) - if err != nil { - return plan, err - } - - if value, present := opts["model_provider"]; present && (value == nil || stringOpt(opts, "model") == "") { - return plan, errors.New("codex: model and complete model_provider are required") - } - - if value, present := opts["web_search"]; present { - switch value { - case "disabled", "cached", "live": - default: - return plan, fmt.Errorf("codex: web_search must be disabled, cached or live") - } - } - - if value, present := opts["model_verbosity"]; present { - switch value { - case "low", "medium", "high": - default: - return plan, fmt.Errorf("codex: model_verbosity must be low, medium or high") - } - } - - resolvedCwd, err := resolveWorkDirCodex(workDir) - if err != nil { - return plan, err - } - plan.Cwd = resolvedCwd - - plan.Model = stringOpt(opts, "model") - plan.SystemPrompt = stringOpt(opts, "system_prompt") - if override := stringOpt(opts, "override_system_prompt"); override != "" { - plan.SystemPrompt = override - } - if mode := CollaborationModeKind(stringOpt(opts, "mode")); mode != "" { - switch mode { - case CollaborationModeDefault, CollaborationModePlan: - plan.CollaborationMode = mode - default: - return plan, fmt.Errorf("codex: unsupported collaboration mode %q", mode) - } - } - - env, err := buildSessionEnv(opts) - if err != nil { - return plan, err - } - - codexHome, err := allocCodexHome(agentStateKey) - if err != nil { - return plan, err - } - if err := resetGeneratedConfig(codexHome); err != nil { - return plan, err - } - env = append(env, "CODEX_HOME="+codexHome) - - // MCP servers come pre-rendered from server/internal/connector/agentdaemon - // (capabilityAdditions.MCPServers, rendered via render.TargetCodex) - // as a map of name → {command,args,env}. Write them into - // /config.toml so codex picks them up on startup. - mcpServers, err := normaliseMCPServers(opts["mcp_servers"]) - if err != nil { - return plan, err - } - if len(mcpServers) > 0 { - if err := writeCodexMCPConfig(codexHome, mcpServers); err != nil { - return plan, err - } - } - - provider, hasProvider, err := normaliseProviderConfig(opts["model_provider"]) - if err != nil { - cleanup() - return plan, err - } - if hasProvider { - if err := writeCodexProviderConfig(codexHome, provider); err != nil { - cleanup() - return plan, err - } - plan.ModelProvider = oacProviderSlug - } - - plan.Env = env - plan.Cleanup = cleanup - plan.ExtraConfig = extraConfigFromOpts(opts) - if effort, ok := nativeConfig["model_reasoning_effort"].(string); ok { - plan.ModelReasoningEffort = effort - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_reasoning_effort", strconv(effort)}) - } - if plan.ModelProvider != "" { - // Pin model_provider at the CLI layer so codex skips its builtin - // "openai" provider — without this the [model_providers.oac] - // block we wrote into config.toml would be loaded but never - // selected (the default model_provider is "openai"). - plan.ExtraConfig = append(plan.ExtraConfig, - [2]string{"model_provider", strconv(plan.ModelProvider)}) - } - plan.EnableFeatures = stringListOpt(opts, "enable_features") - plan.DisableFeatures = stringListOpt(opts, "disable_features") - return plan, nil -} - -// --------------------------------------------------------------------------- -// helpers -// --------------------------------------------------------------------------- - -func resolveWorkDirCodex(input string) (string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", nil - } - var abs string - switch { - case strings.HasPrefix(trimmed, "~/"): - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("codex: resolve home dir: %w", err) - } - abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - case filepath.IsAbs(trimmed): - abs = trimmed - default: - return "", fmt.Errorf("codex: work_dir must be absolute or start with ~/, got %q", trimmed) - } - // Match claudecode's resolveSessionWorkDir: mkdir -p so a user - // naming a fresh project root in the agent wizard works on first - // run instead of erroring with "does not exist". - if err := os.MkdirAll(abs, 0o755); err != nil { - return "", fmt.Errorf("codex: mkdir work_dir %s: %w", abs, err) - } - return abs, nil -} - -func allocCodexHome(agentStateKey string) (string, error) { - if strings.TrimSpace(agentStateKey) == "" { - return "", fmt.Errorf("codex: agentStateKey required for CODEX_HOME allocation") - } - root, err := paths.Root() - if err != nil { - return "", err - } - parts := strings.Split(agentStateKey, "/") - safeParts := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := safePathPartCodex(part); safe != "" { - safeParts = append(safeParts, safe) - } - } - if len(safeParts) == 0 { - return "", fmt.Errorf("codex: invalid agentStateKey %q", agentStateKey) - } - dirParts := append([]string{root, "daemon", "agent-sessions"}, safeParts...) - dir := filepath.Join(dirParts...) - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("codex: create CODEX_HOME %s: %w", dir, err) - } - return dir, nil -} - -func resetGeneratedConfig(codexHome string) error { - path := filepath.Join(codexHome, "config.toml") - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("codex: remove generated config %s: %w", path, err) - } - return nil -} - -func buildSessionEnv(opts map[string]any) ([]string, error) { - env := []string{ - "DISABLE_TELEMETRY=1", - } - raw, ok := opts["env"] - if !ok || raw == nil { - return env, nil - } - envMap, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex.BuildSessionPlan: env must be object, got %T", raw) - } - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return nil, fmt.Errorf("codex.BuildSessionPlan: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - return env, nil -} - -func stringListOpt(opts map[string]any, key string) []string { - if opts == nil { - return nil - } - raw, ok := opts[key] - if !ok || raw == nil { - return nil - } - arr, ok := raw.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(arr)) - for _, v := range arr { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - out = append(out, s) - } - } - return out -} - -func normaliseMCPServers(raw any) (map[string]mcpServerConfig, error) { - if raw == nil { - return nil, nil - } - m, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex: mcp_servers must be object, got %T", raw) - } - out := make(map[string]mcpServerConfig, len(m)) - for name, v := range m { - entry, ok := v.(map[string]any) - if !ok { - return nil, fmt.Errorf("codex: mcp_servers[%q] must be object, got %T", name, v) - } - srv := mcpServerConfig{Name: name} - if url, ok := entry["url"].(string); ok { - srv.URL = strings.TrimSpace(url) - } - if cmd, ok := entry["command"].(string); ok { - srv.Command = cmd - } - if args, ok := entry["args"].([]any); ok { - for _, a := range args { - if s, ok := a.(string); ok { - srv.Args = append(srv.Args, s) - } - } - } - if env, ok := entry["env"].(map[string]any); ok { - srv.Env = make(map[string]string, len(env)) - for k, val := range env { - if s, ok := val.(string); ok { - srv.Env[k] = s - } - } - } - if headers, ok := entry["headers"].(map[string]any); ok { - srv.Headers = make(map[string]string, len(headers)) - for key, value := range headers { - if text, ok := value.(string); ok { - srv.Headers[key] = text - } - } - } else if headers, ok := entry["headers"].(map[string]string); ok { - srv.Headers = headers - } - if srv.Command == "" && srv.URL == "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] missing command or url", name) - } - if srv.Command != "" && srv.URL != "" { - return nil, fmt.Errorf("codex: mcp_servers[%q] cannot set both command and url", name) - } - out[name] = srv - } - return out, nil -} - -// normaliseProviderConfig validates and renders the frozen native provider. -func normaliseProviderConfig(raw any) (providerConfig, bool, error) { - if raw == nil { - return providerConfig{}, false, nil - } - provider, err := harnessconfiguration.Configuration().ParseProvider(raw) - if err != nil { - return providerConfig{}, false, err - } - return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"}, true, nil -} - -// intOpt extracts an integer-shaped value from a map. JSON-decoded -// numbers arrive as float64; tests sometimes pass int directly. Both -// are accepted; non-numeric / missing yields 0. -func intOpt(m map[string]any, key string) int { - v, ok := m[key] - if !ok || v == nil { - return 0 - } - switch x := v.(type) { - case int: - return x - case int64: - return int(x) - case float64: - return int(x) - } - return 0 -} - -func stringOpt(opts map[string]any, key string) string { - if opts == nil { - return "" - } - v, ok := opts[key] - if !ok || v == nil { - return "" - } - s, ok := v.(string) - if !ok { - return "" - } - return strings.TrimSpace(s) -} - -func safePathPartCodex(runID string) string { - var b strings.Builder - for _, r := range runID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "" { - return "run" - } - return out -} - -// strconv quotes a value as a TOML string. Done by reusing the JSON -// encoder for escape rules — TOML strings accept the same standard -// escape set so this is wire-safe. -func strconv(s string) string { - q, _ := json.Marshal(s) - return string(q) -} diff --git a/apps/parsar-daemon/internal/agent/codex/options_test.go b/apps/parsar-daemon/internal/agent/codex/options_test.go deleted file mode 100644 index b8f99d039..000000000 --- a/apps/parsar-daemon/internal/agent/codex/options_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package codex - -import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", nil) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - if plan.ApprovalPolicy.String != "never" { - t.Fatalf("default policy must bypass approvals, got %+v", plan.ApprovalPolicy) - } - if plan.Sandbox != SandboxDangerFullAcces { - t.Fatalf("default sandbox = %s, want danger-full-access", plan.Sandbox) - } - if plan.Cleanup == nil { - t.Fatal("Cleanup must be non-nil") - } - plan.Cleanup() -} - -func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "env": map[string]any{ - "OPENAI_API_KEY": "sk-test", - }, - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - hasCodexHome := false - hasOpenAI := false - hasTelemetry := false - for _, kv := range plan.Env { - switch { - case strings.HasPrefix(kv, "CODEX_HOME="): - hasCodexHome = true - case kv == "OPENAI_API_KEY=sk-test": - hasOpenAI = true - case kv == "DISABLE_TELEMETRY=1": - hasTelemetry = true - } - } - if !hasCodexHome { - t.Fatalf("env missing CODEX_HOME: %+v", plan.Env) - } - if !hasOpenAI { - t.Fatalf("env missing OPENAI_API_KEY: %+v", plan.Env) - } - if !hasTelemetry { - t.Fatalf("env missing DISABLE_TELEMETRY: %+v", plan.Env) - } -} - -func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { - stateKey := "conv-stable/agent-stable/codex" - planA, err := BuildSessionPlan("run-a", stateKey, "", nil) - if err != nil { - t.Fatalf("BuildSessionPlan A: %v", err) - } - planB, err := BuildSessionPlan("run-b", stateKey, "", nil) - if err != nil { - t.Fatalf("BuildSessionPlan B: %v", err) - } - if codexHomeFromEnv(planA.Env) == "" || codexHomeFromEnv(planA.Env) != codexHomeFromEnv(planB.Env) { - t.Fatalf("CODEX_HOME must be stable by state key: A=%q B=%q", codexHomeFromEnv(planA.Env), codexHomeFromEnv(planB.Env)) - } -} - -func TestBuildSessionPlan_RoutesReasoningSummary(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "reasoning_summary": "detailed", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if len(plan.ExtraConfig) != 1 { - t.Fatalf("ExtraConfig = %+v", plan.ExtraConfig) - } - kv := plan.ExtraConfig[0] - if kv[0] != "model_reasoning_summary" { - t.Fatalf("override key = %q", kv[0]) - } - if kv[1] != `"detailed"` { - t.Fatalf("override value = %q (must be TOML-quoted)", kv[1]) - } -} - -func codexHomeFromEnv(env []string) string { - for _, kv := range env { - if strings.HasPrefix(kv, "CODEX_HOME=") { - return strings.TrimPrefix(kv, "CODEX_HOME=") - } - } - return "" -} - -func TestBuildSessionPlan_OverrideSystemPromptReplacesAppend(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "system_prompt": "user base", - "override_system_prompt": "you are pirate", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.SystemPrompt != "you are pirate" { - t.Fatalf("SystemPrompt = %q, want %q", plan.SystemPrompt, "you are pirate") - } -} - -func TestBuildSessionPlan_EmptyOverrideKeepsSystemPrompt(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "system_prompt": "user base", - "override_system_prompt": "", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.SystemPrompt != "user base" { - t.Fatalf("SystemPrompt = %q, want %q (empty override should not clobber)", plan.SystemPrompt, "user base") - } -} - -func TestBuildSessionPlan_ParsesCollaborationMode(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "mode": "plan", - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModePlan { - t.Fatalf("CollaborationMode = %q, want plan", plan.CollaborationMode) - } -} - -func TestBuildSessionPlan_OmittedModeRetainsCurrentInstructions(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, mode := range []string{"", "default"} { - opts := map[string]any{"system_prompt": "current reference", "model": "MiniMax-M3"} - if mode != "" { - opts["mode"] = mode - } - plan, err := BuildSessionPlan("run", "conv/agent/codex", "", opts) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if plan.CollaborationMode != CollaborationModeDefault || plan.SystemPrompt != "current reference" || plan.Model != "MiniMax-M3" { - t.Fatalf("mode %q did not retain the default turn instructions: %+v", mode, plan) - } - } -} - -func TestBuildSessionPlan_RejectsUnknownCollaborationMode(t *testing.T) { - _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "mode": "autopilot", - }) - if err == nil || !strings.Contains(err.Error(), "unsupported collaboration mode") { - t.Fatalf("expected unsupported collaboration mode error, got %v", err) - } -} - -func TestBuildSessionPlan_RejectsRelativeWorkDir(t *testing.T) { - _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "relative/dir", nil) - if err == nil { - t.Fatal("relative work_dir must error") - } -} - -// TestBuildSessionPlan_CreatesMissingWorkDir: align with claudecode — -// a non-existent absolute path is mkdir -p'd so a user can pin a fresh -// project root in the agent wizard. Without this, codex agents would -// hard-fail the first turn instead of running. -func TestBuildSessionPlan_CreatesMissingWorkDir(t *testing.T) { - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", target, nil) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - if plan.Cwd != target { - t.Fatalf("plan.Cwd = %q, want %q", plan.Cwd, target) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -func TestBuildSessionPlan_WritesMCPConfig(t *testing.T) { - plan, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "mcp_servers": map[string]any{ - "docs": map[string]any{ - "command": "docs-server", - "args": []any{"--port", "8080"}, - "env": map[string]any{"TOKEN": "abc"}, - }, - }, - }) - if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) - } - defer plan.Cleanup() - // Find CODEX_HOME so we can verify the config.toml was written there. - codexHome := "" - for _, kv := range plan.Env { - if strings.HasPrefix(kv, "CODEX_HOME=") { - codexHome = strings.TrimPrefix(kv, "CODEX_HOME=") - break - } - } - if codexHome == "" { - t.Fatal("CODEX_HOME not in plan.Env") - } - // mcp_config.go writes /config.toml — verify it exists - // and contains the rendered server. - bodyBytes, err := os.ReadFile(codexHome + "/config.toml") - if err != nil { - t.Fatalf("read config.toml: %v", err) - } - body := string(bodyBytes) - if !strings.Contains(body, `[mcp_servers."docs"]`) { - t.Fatalf("config.toml missing docs server: %s", body) - } - if !strings.Contains(body, `command = "docs-server"`) { - t.Fatalf("config.toml missing command: %s", body) - } -} - -func TestBuildSessionPlan_MissingMCPCommandErrors(t *testing.T) { - _, err := BuildSessionPlan("run-1", "conv-1/agent-1/codex", "", map[string]any{ - "mcp_servers": map[string]any{ - "broken": map[string]any{ - "args": []any{"--x"}, - // no command - }, - }, - }) - if err == nil { - t.Fatal("missing mcp command must surface as error") - } -} - -func TestNativeInputPreservesText(t *testing.T) { - inputs, err := nativeInput(proto.TextInput(" hello world ")) - if err != nil { - t.Fatal(err) - } - if len(inputs) != 1 { - t.Fatalf("len = %d", len(inputs)) - } - if inputs[0].Type != UserInputText || inputs[0].Text != " hello world " { - t.Fatalf("input = %+v", inputs[0]) - } -} - -func TestFirstUserInput_EmptyReturnsNil(t *testing.T) { - if got, err := nativeInput(proto.TextInput("")); err == nil { - t.Fatalf("empty prompt must return nil, got %+v", got) - } -} - -// The shared validator admits whitespace-only text; Codex receives it unchanged. -func TestFirstUserInput_WhitespaceIsUnchanged(t *testing.T) { - inputs, err := nativeInput(append(proto.TextInput(" "), proto.TextInput("\n\t")...)) - if err != nil || len(inputs) != 3 || inputs[0].Text != " " || inputs[1].Text != "\n\n" || inputs[2].Text != "\n\t" { - t.Fatalf("input = %+v, %v", inputs, err) - } -} - -func TestStringListOpt_FiltersEmpties(t *testing.T) { - got := stringListOpt(map[string]any{ - "enable_features": []any{"a", "", " ", "b"}, - }, "enable_features") - if len(got) != 2 || got[0] != "a" || got[1] != "b" { - t.Fatalf("filter = %+v", got) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go deleted file mode 100644 index 1c14661d0..000000000 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ /dev/null @@ -1,156 +0,0 @@ -package codex - -import ( - "context" - "errors" - "fmt" - "os" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -// Prepare connects the native harness without creating a thread or starting model -// work. req supplies configuration and a stable state key, but no RunID or Prompt. -// owner owns the entire harness lifetime, including the eventual Session; it must -// not be a disposable readiness-request context. Initialization/readiness use their -// existing operation-local deadlines. Close an unused preparation explicitly. -func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { - return newPreparation(owner, req, defaultSessionConfig()) -} - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("codex: nil out channel") - } - runID, prompt := req.RunID, req.Input - req.AgentStateKey = effectiveAgentStateKey(req) - req.RunID, req.Input = "", nil - prepared, err := newPreparation(parent, req, cfg) - if err != nil { - return nil, err - } - defer prepared.Close() - return prepared.start(parent, runID, prompt, out) -} - -func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Prepared, error) { - if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { - return nil, errors.New("codex: structured output is not qualified") - } - if req.WorkspaceReadOnly { - return nil, errors.New("codex: workspace reads use the local Runtime interface") - } - if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { - return nil, errors.New("codex: native-session recovery requires strict private state") - } - if req.RunID != "" || len(req.Input) != 0 { - return nil, errors.New("codex: preparation does not accept a run identity or prompt") - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.codexBinary == "" { - cfg.codexBinary = defaultBinary() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = rpcKillTimeout - } - functions, err := prepareFunctionTools(req.FunctionTools) - if err != nil { - return nil, err - } - req.AgentStateKey = effectiveAgentStateKey(req) - - req.AgentOptions = executionOptions(req) - plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) - if err != nil { - return nil, err - } - - cancelCtx, cancelFn := context.WithCancel(parent) - - rpcCfg := JSONRPCConfig{ - Binary: cfg.codexBinary, - EnableFeatures: plan.EnableFeatures, - DisableFeatures: plan.DisableFeatures, - Cwd: plan.Cwd, - Env: append(os.Environ(), plan.Env...), - LogTag: "codex-preparation", - Logger: cfg.logger, - } - for _, kv := range plan.ExtraConfig { - rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) - } - - rpc := NewJSONRPCClient(rpcCfg) - - s := &Session{ - nativeHome: nativeHomeFromPlan(plan), - functions: functions, - observeMessages: req.ObserveMessages, - - observeToolObservations: req.ObserveToolObservations, - observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, - cfg: cfg, - rpc: rpc, - cancelCtx: cancelCtx, - cancelFn: cancelFn, - waitDone: make(chan struct{}), - cleanup: sync.OnceFunc(plan.Cleanup), - bufs: NewItemBuffers(), - resolvedModel: plan.Model, - interactions: newPendingCodexInteractions(), - } - plan.Cleanup = s.cleanup - p := &Prepared{ - session: s, plan: plan, - resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, - transferred: make(chan struct{}), - } - - initParams := InitializeParams{ - ClientInfo: InitializeClientInfo{Name: "oac-daemon", Version: "0.0.0"}, - Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, - } - if _, err := rpc.Start(cancelCtx, initParams); err != nil { - return p.preparationFailed(fmt.Errorf("codex: rpc start: %w", err)) - } - if req.ExecutionControls != nil && req.ExecutionControls.DisableProgrammaticToolCalling { - if err := verifyProgrammaticToolsDisabled(cancelCtx, rpc); err != nil { - return p.preparationFailed(err) - } - } - if req.DisableExecutionEnvironment { - if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { - return p.preparationFailed(err) - } - } - if s.observeSubagentIdentities { - if err := verifySubagentObservationProfile(cancelCtx, rpc, plan.Cwd); err != nil { - return p.preparationFailed(err) - } - } - - if plan.mcpServers != nil { - if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { - return p.preparationFailed(err) - } - } - if len(skillRoots) > 0 { - if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { - return p.preparationFailed(fmt.Errorf("codex: register skill root: %w", err)) - } - } - - go p.watchOwner() - return p, nil -} - -func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { - if err := p.Close(); err != nil { - return p, errors.Join(cause, err) - } - return nil, cause -} diff --git a/apps/parsar-daemon/internal/agent/codex/recovery_test.go b/apps/parsar-daemon/internal/agent/codex/recovery_test.go deleted file mode 100644 index 372102bc3..000000000 --- a/apps/parsar-daemon/internal/agent/codex/recovery_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -func TestNativeSessionRecoveryRequiresPinnedNative(t *testing.T) { - for _, version := range []string{"", "codex-cli 0.153.3", "codex-cli 0.153.4", "codex-cli 0.154.0"} { - if SupportsNativeSessionRecovery(version) != (version == "codex-cli 0.153.4") { - t.Fatalf("unexpected recovery capability for %q", version) - } - } -} - -func TestRequiredHistoryResolution(t *testing.T) { - for _, scenario := range []string{"recover", "fresh", "known", "missing", "ambiguous", "paged", "omitted-cursor", "missing-parent", "child", "fork", "ephemeral", "wrong-cwd", "wrong-home", "archived", "lookup-error", "resume-error", "malformed"} { - t.Run(scenario, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - home := t.TempDir() - plan := SessionPlan{Cwd: "/workspace", Env: []string{"CODEX_HOME=" + home}} - row := map[string]any{"id": "original", "parentThreadId": nil, "forkedFromId": nil, "ephemeral": false, "source": "vscode", "cwd": plan.Cwd, "path": filepath.Join(home, "sessions", "rollout.jsonl")} - switch scenario { - case "missing-parent": - delete(row, "parentThreadId") - case "child": - row["parentThreadId"] = "parent" - case "fork": - row["forkedFromId"] = "old" - case "ephemeral": - row["ephemeral"] = true - case "wrong-cwd": - row["cwd"] = "/another" - case "wrong-home": - row["path"] = "/another/sessions/rollout.jsonl" - } - req := proto.PromptRequestPayload{StrictResume: true, RequireExistingNativeSession: true} - if scenario == "fresh" { - req.RequireExistingNativeSession = false - } - if scenario == "known" { - req.AgentSessionID = "original" - } - session := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "recovery-test")}} - methods := make(chan []string, 1) - go func() { - decoder, encoder := json.NewDecoder(server.FromClient), json.NewEncoder(server.ToClient) - var seen []string - defer func() { methods <- seen }() - for { - var frame struct { - ID string `json:"id"` - Method string `json:"method"` - Params map[string]any `json:"params"` - } - if decoder.Decode(&frame) != nil { - return - } - seen = append(seen, frame.Method) - response := map[string]any{"id": frame.ID} - switch frame.Method { - case "thread/list": - if frame.Params["limit"] != float64(2) || frame.Params["useStateDbOnly"] != false { - return - } - data := []any{row} - if frame.Params["archived"] == true || scenario == "missing" { - data = []any{} - } - if scenario == "archived" && frame.Params["archived"] == true { - data = []any{row} - } - if scenario == "ambiguous" { - data = append(data, row) - } - page := map[string]any{"data": data, "nextCursor": nil} - if scenario == "paged" { - page["nextCursor"] = "next" - } - if scenario == "omitted-cursor" { - delete(page, "nextCursor") - } - response["result"] = page - if scenario == "malformed" { - response["result"] = "invalid" - } - if scenario == "lookup-error" { - delete(response, "result") - response["error"] = map[string]any{"code": -1, "message": "failed"} - } - case "thread/resume": - if frame.Params["threadId"] != "original" { - return - } - response["result"] = map[string]any{"thread": map[string]string{"id": "original"}} - if scenario == "resume-error" { - delete(response, "result") - response["error"] = map[string]any{"code": -1, "message": "failed"} - } - case "thread/start": - response["result"] = map[string]any{"thread": map[string]string{"id": "fresh"}} - default: - return - } - if encoder.Encode(response) != nil { - return - } - } - }() - err := session.resolveThread(req, plan) - wantSuccess := scenario == "recover" || scenario == "fresh" || scenario == "known" - if (err == nil) != wantSuccess { - t.Fatalf("resolution error=%v", err) - } - cleanup() - seen := <-methods - for _, method := range seen { - if method == "thread/start" && scenario != "fresh" { - t.Fatal("silently created fresh history", seen) - } - if method == "thread/list" && scenario == "known" { - t.Fatal("searched instead of using authoritative ID", seen) - } - } - if scenario == "recover" && (len(seen) != 3 || seen[2] != "thread/resume" || session.currentThreadID() != "original") { - t.Fatal("did not resume exact root", seen) - } - }) - } -} - -func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.RequireExistingNativeSession = true - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - assertPreparationOnly(t, root) - out := make(chan proto.Envelope, 16) - session, err := p.Start(t.Context(), "recovery-run", proto.TextInput("continue"), out) - if err != nil { - t.Fatal(err) - } - defer session.Cancel(context.Background()) - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("recovery did not terminate") - } - found := false - for _, frame := range preparationFrames(t, root) { - if frame.Method == "thread/list" { - found = true - } - if frame.Method == "thread/start" || frame.Method == "turn/start" { - t.Fatal("missing history started work", frame.Method) - } - } - if !found { - t.Fatal("prepared start lost recovery requirement") - } -} - -func TestRecoveryRequiresStrictPrivateExecution(t *testing.T) { - for _, mode := range []string{"non-strict", "no-state", "read-only"} { - t.Run(mode, func(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.RequireExistingNativeSession = true - switch mode { - case "non-strict": - req.StrictResume = false - case "no-state": - req.AgentStateKey = "" - case "read-only": - req.WorkspaceReadOnly = true - } - if p, err := newPreparation(t.Context(), req, cfg); err == nil { - p.Close() - t.Fatal("invalid recovery admitted") - } - if len(preparationFrames(t, root)) != 0 { - t.Fatal("invalid recovery launched native process") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go deleted file mode 100644 index 8c520bf50..000000000 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ /dev/null @@ -1,504 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -// terminalSendTimeout caps how long the session waits to deliver the -// final done / error envelope on the upstream channel. Matches the -// claudecode + opencode safety net. -const terminalSendTimeout = 2 * time.Second - -// sessionConfig is the cross-cutting knob bag — production callers go -// through Factory which uses defaults. -type sessionConfig struct { - codexBinary string - logger *slog.Logger - killTimeout time.Duration -} - -func defaultSessionConfig() sessionConfig { - return sessionConfig{ - codexBinary: defaultBinary(), - logger: obslog.Bg(), - killTimeout: rpcKillTimeout, - } -} - -// Factory implements agent.Factory for agent_kind="codex". Spawns one -// codex app-server child per prompt. The run stream closes when the turn -// completes; the router retains the child until the conversation's idle -// window expires or cancellation shuts it down sooner. -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultSessionConfig()) -} - -// Session implements agent.Session. State lifecycle: -// -// 1. Preparation initializes RPC and verifies the selected environment. -// 2. Start transfers that RPC and wires notification/server-request handlers. -// 3. thread/start or thread/resume runs (resume falls back to start). -// 4. turn/start delivers the user prompt; subsequent stream notifications -// fan out to proto.Envelope via session_items.go. -// 5. turn/completed emits TypeDone + closes out. Cancel can short-cut -// this by killing the child early. -type Session struct { - retiredTurns map[string]bool - executor *Executor - outputDone chan struct{} - nativeSettled atomic.Bool - settlement agent.TurnSettlement - settlementErr error - terminalCleanupMu sync.Mutex - operationMu sync.Mutex - operations sync.WaitGroup - operationsClosed bool - nativeHome string - subagents *subagentObservations - observeSubagentIdentities bool - functions *functionCalls - observeMessages bool - - observeToolObservations bool - runID string - cfg sessionConfig - out chan<- proto.Envelope - rpc *JSONRPCClient - - cancelCtx context.Context - cancelFn context.CancelFunc - - cancelErr error - cancelOnce sync.Once - cancelReady chan struct{} - cancelled atomic.Bool - terminal atomic.Bool - closeOutOnce sync.Once - outMu sync.RWMutex - outClosed bool - waitDone chan struct{} - cleanup func() - - threadIDMu sync.Mutex - threadID string - steering steeringTurn - - deltaSeq atomic.Uint64 - thinkingSeq atomic.Uint64 - - bufs *ItemBuffers - - usageMu sync.Mutex - latestUsage *TurnUsage - usageTotal TurnUsage - usageBaseline TurnUsage - usageTurnID string - resumeUsageThreadID string - resumeUsageTotal *TurnUsage - resolvedModel string - - finalTextMu sync.Mutex - finalText string - lastErrText string - - interactions *pendingCodexInteractions - outcome cancellationOutcomeState -} - -var _ agent.Session = (*Session)(nil) - -// SubmitPermission completes the deferred Codex app-server request that -// produced the Core permission envelope. -func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { - return s.submitCodexPermission(permID, decision) -} - -// SubmitPromptForUserChoice maps Core's header/answer pairs back to -// Codex's question-id keyed requestUserInput response. -func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - return s.submitCodexUserInput(askID, decision) -} - -// --------------------------------------------------------------------------- -// notification handlers -// --------------------------------------------------------------------------- - -func (s *Session) registerHandlers() { - rpc := s.rpc - - rpc.OnNotification("thread/started", func(_ json.RawMessage) {}) - rpc.OnNotification("turn/started", s.onTurnStarted) - rpc.OnNotification("turn/completed", s.onTurnCompleted) - rpc.OnNotification("turn/failed", s.onTurnFailed) - rpc.OnNotification("item/started", s.onItemStarted) - rpc.OnNotification("item/updated", func(_ json.RawMessage) {}) // silenced - rpc.OnNotification("item/completed", s.onItemCompleted) - rpc.OnNotification("item/agentMessage/delta", s.onAgentDelta) - rpc.OnNotification("item/commandExecution/outputDelta", s.onCommandOutput) - rpc.OnNotification("item/reasoning/textDelta", s.onReasoningDelta) - rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) - rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) - rpc.OnNotification("error", s.onErrorNotif) - - s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) - s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) - s.onServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) - // Older app-server releases used this unseparated method name. - s.onServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) - s.onServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) - s.onServerRequest("item/tool/call", s.handleFunctionCall) - s.onServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) -} - -func (s *Session) onTurnStarted(raw json.RawMessage) { - var p TurnStartedNotification - if json.Unmarshal(raw, &p) == nil { - s.beginRootTurn(p.ThreadID, p.Turn.ID) - } -} - -func (s *Session) onReasoningDelta(raw json.RawMessage) { - var p AgentMessageDeltaNotification - if err := json.Unmarshal(raw, &p); err != nil { - return - } - if !s.isRootTurn(p.ThreadID, p.TurnID) || p.Delta == "" || p.ItemID == "" { - return - } - _ = FoldDeltaIntoBuffer(s.bufs, "reasoning", p.ItemID, p.Delta) - seq := s.thinkingSeq.Add(1) - env, err := proto.NewEnvelope(proto.TypeThinking, s.runID, proto.ThinkingPayload{Text: p.Delta, Sequence: seq}) - if err != nil { - return - } - s.trySend(env) -} - -func (s *Session) onTurnCompleted(raw json.RawMessage) { - s.outcome.notificationMu.Lock() - defer s.outcome.notificationMu.Unlock() - var p TurnCompletedNotification - if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { - return - } - s.nativeSettled.Store(true) - s.stopSteering() - - usage := p.Turn.Usage - if usage == nil { - s.usageMu.Lock() - usage = s.latestUsage - s.usageMu.Unlock() - } - if usage != nil { - s.usageMu.Lock() - s.latestUsage = usage - s.usageMu.Unlock() - s.emitUsage(*usage) - } - - status := strings.ToLower(p.Turn.Status) - finalText := s.takeFinalText() - errText := s.takeLastErrText() - // Always log the turn outcome — operators need this when a prompt - // "completes" with no agent message (e.g. codex bailed before the - // model ran because the sandbox mode was misinterpreted as - // read-only) so the empty body in the upstream Done frame can be - // correlated with the turn status that produced it. - s.cfg.logger.Info("codex: turn/completed", - "run_id", s.runID, - "turn_id", p.Turn.ID, - "status", p.Turn.Status, - "final_text_len", len(finalText), - "buffered_err_text_len", len(errText), - "raw_payload", string(raw)) - if status == "failed" { - // Body precedence on failure: - // 1. agent's final text (rare on hard failures but exists for - // partial completions that still surface a message) - // 2. codex's turn.error.message — this is where gateway / - // provider errors land (e.g. an upstream gateway's "X-Sub-Module is - // not allowed for this API key"). Without forwarding it - // the upstream connector reports "empty final output" and - // operators can't see why a key was rejected. - // 3. buffered text from the "error" notification stream - // (sandbox warnings, late stream packets) — last because - // it's noisier than turn.error. - body := finalText - if turnErrMsg := turnErrorMessage(p.Turn.Error); turnErrMsg != "" { - body = appendOnNewline(body, turnErrMsg) - } - if errText != "" { - body = appendOnNewline(body, errText) - } - s.emitTerminalFailure(body, true, classifyTurnError(p.Turn.Error)) - s.finishAfterTerminal() - return - } - var completedAt *int64 - if status == "completed" { - completedAt = nativeMilliseconds(p.Turn.CompletedAt) - } - s.emitDoneAt(finalText, usage, completedAt) - s.finishAfterTerminal() -} - -// turnErrorMessage extracts a human-readable error string from -// codex's TurnError. Some gateways (an OpenAI-Responses-style proxy -// is one) JSON-encode the upstream error body and stuff it -// into Message verbatim; in that case unwrap one layer so the -// operator sees the inner code/message instead of escaped JSON. -func turnErrorMessage(te *TurnError) string { - if te == nil { - return "" - } - raw := strings.TrimSpace(te.Message) - if raw == "" { - return "" - } - // Try to peel off a {"error":{"code","message","type"}} wrapper. - var inner struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - Type string `json:"type"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(raw), &inner); err == nil && inner.Error.Message != "" { - if inner.Error.Code != "" { - return fmt.Sprintf("%s: %s", inner.Error.Code, inner.Error.Message) - } - return inner.Error.Message - } - return raw -} - -func appendOnNewline(base, extra string) string { - if base == "" { - return extra - } - if extra == "" { - return base - } - return base + "\n\n" + extra -} - -func (s *Session) onTurnFailed(raw json.RawMessage) { - var p TurnCompletedNotification - if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { - return - } - // turn/failed carries no payload detail today; the actual cause - // usually arrived earlier on the "error" notification stream and is - // already buffered in lastErrText. Log both so post-mortems can - // correlate the failure to whatever upstream codex saw. - s.cfg.logger.Warn("codex: turn/failed received", - "run_id", s.runID, - "turn_id", p.Turn.ID, - "turn_status", p.Turn.Status, - "last_err_text_present", s.peekLastErrText() != "") - s.emitTerminal("codex: turn failed", true) - s.finishAfterTerminal() -} - -func (s *Session) onErrorNotif(raw json.RawMessage) { - var p ErrorNotification - if err := json.Unmarshal(raw, &p); err != nil { - return - } - if !s.isRootTurn(p.ThreadID, p.TurnID) { - return - } - if p.Error != nil { - p.Message = turnErrorMessage(p.Error) - } - if p.Message == "" { - return - } - // Always log: codex's `error` notification is the *only* channel that - // surfaces gateway / model-provider failures (401 on a custom header, - // 400 from Azure missing api-version, etc.). Buffering it for the - // eventual turn/completed message body is correct, but without a log - // the daemon shows 13s of silence then a TypeError that the upstream - // can't decode. - s.cfg.logger.Warn("codex: error notification received", - "run_id", s.runID, - "thread_id", s.currentThreadID(), - "message", p.Message) - s.finalTextMu.Lock() - s.lastErrText = p.Message - s.finalTextMu.Unlock() -} - -// peekLastErrText reads lastErrText without consuming it, used by -// loggers that want to record "we have a buffered upstream error" -// without racing with the takeLastErrText path that emitDone uses. -func (s *Session) peekLastErrText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() - return s.lastErrText -} - -// --------------------------------------------------------------------------- -// envelope emit helpers -// --------------------------------------------------------------------------- - -func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int64) { - if !s.terminal.CompareAndSwap(false, true) { - return - } - s.stopSteering() - s.stopFunctionCalls() - doneMeta := map[string]any{} - if tid := s.currentThreadID(); tid != "" { - doneMeta[proto.DoneMetaAgentSessionID] = tid - doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" - } - payload := proto.DonePayload{Content: content, Metadata: doneMeta, SourceCompletedAtMS: completedAt} - if usage != nil { - payload.Usage = s.usagePayload(*usage) - } - payload = s.rememberOutcome(payload) - env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) - if err != nil { - return - } - s.sendTerminal(env) -} - -func (s *Session) emitUsage(u TurnUsage) { - env, err := proto.NewEnvelope(proto.TypeUsage, s.runID, proto.UsagePayload{ - Usage: s.usagePayload(u), - }) - if err != nil { - return - } - s.trySend(env) -} - -func (s *Session) emitTerminal(message string, asError bool) { - s.emitTerminalFailure(message, asError, proto.ErrorPayload{}) -} - -func (s *Session) emitTerminalFailure(message string, asError bool, failure proto.ErrorPayload) { - if s.executor != nil { - defer s.finishAfterTerminal() - } - if !s.terminal.CompareAndSwap(false, true) { - return - } - s.stopSteering() - s.stopFunctionCalls() - // Always log: this is the only place the daemon decides "the prompt is - // over, here's what went wrong (if anything)". Without this, post- - // mortem requires correlating server-side TypeError frames against - // daemon timestamps with no message body anywhere. - if asError { - s.cfg.logger.Warn("codex: emitting terminal error", - "run_id", s.runID, - "thread_id", s.currentThreadID(), - "message", message) - } else { - s.cfg.logger.Info("codex: emitting terminal done", - "run_id", s.runID, - "thread_id", s.currentThreadID(), - "message_len", len(message)) - } - var events []proto.Envelope - if asError { - failure.Error = message - env, err := proto.NewEnvelope(proto.TypeError, s.runID, failure) - if err == nil { - events = append(events, env) - } - } - doneMeta := map[string]any{} - if tid := s.currentThreadID(); tid != "" { - doneMeta[proto.DoneMetaAgentSessionID] = tid - doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" - } - payload := proto.DonePayload{ - Content: message, - Metadata: doneMeta, - } - payload = s.rememberOutcome(payload) - env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) - if err != nil { - return - } - s.sendTerminal(append(events, env)...) -} - -func (s *Session) closeOut() { - s.stopSteering() - s.closeOutOnce.Do(func() { - s.outMu.Lock() - s.outClosed = true - close(s.out) - if s.outputDone != nil { - close(s.outputDone) - } - s.outMu.Unlock() - }) -} - -func (s *Session) finishAfterTerminal() { - if s.subagents == nil { - s.closeOut() - } -} - -// --------------------------------------------------------------------------- -// small accessors -// --------------------------------------------------------------------------- - -func (s *Session) currentThreadID() string { - s.threadIDMu.Lock() - defer s.threadIDMu.Unlock() - return s.threadID -} - -func (s *Session) setThreadID(id string) { - s.threadIDMu.Lock() - if s.threadID == "" { - s.threadID = id - } - s.threadIDMu.Unlock() -} - -func (s *Session) appendFinalText(text string) { - s.finalTextMu.Lock() - if s.finalText != "" { - s.finalText = s.finalText + "\n\n" + text - } else { - s.finalText = text - } - s.finalTextMu.Unlock() -} - -func (s *Session) takeFinalText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() - t := s.finalText - s.finalText = "" - return t -} - -func (s *Session) takeLastErrText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() - e := s.lastErrText - s.lastErrText = "" - return e -} diff --git a/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go b/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go deleted file mode 100644 index 02c8a71b9..000000000 --- a/apps/parsar-daemon/internal/agent/codex/session_knowledge_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "testing" - "time" -) - -func TestResumeRefreshesReferenceContext(t *testing.T) { - for _, prompt := range []string{"updated knowledge reference", ""} { - t.Run(prompt, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cfg: sessionConfig{logger: log.With("component", "knowledge-test")}} - done := make(chan error, 1) - go func() { done <- s.resumeThread("same-thread", SessionPlan{SystemPrompt: prompt}) }() - var request struct { - ID string `json:"id"` - Params map[string]any `json:"params"` - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if value, ok := request.Params["developerInstructions"]; !ok || value != prompt { - t.Fatal("resume did not replace the old instructions") - } - if request.Params["threadId"] != "same-thread" { - t.Fatal("lost history") - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]string{"id": "same-thread"}}}); err != nil { - t.Fatal(err) - } - if err := <-done; err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools.go b/apps/parsar-daemon/internal/agent/codex/session_tools.go deleted file mode 100644 index 5c9856397..000000000 --- a/apps/parsar-daemon/internal/agent/codex/session_tools.go +++ /dev/null @@ -1,38 +0,0 @@ -package codex - -import ( - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawMessage) { - var native struct { - Item json.RawMessage `json:"item"` - } - if s.observeToolObservations { - if err := json.Unmarshal(notification, &native); err != nil { - return - } - } - for _, event := range events { - if (s.observeToolObservations) && event.Type == proto.TypeToolCall { - var tool proto.ToolCallPayload - if err := event.DecodePayload(&tool); err != nil { - return - } - var err error - tool.Observation, err = normalizeToolObservation(tool.ID, tool.Stage, native.Item) - if err != nil { - s.emitTerminal("codex: invalid tool observation", true) - return - } - payload, err := json.Marshal(tool) - if err != nil { - return - } - event.Payload = payload - } - s.trySend(event) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/skills.go b/apps/parsar-daemon/internal/agent/codex/skills.go deleted file mode 100644 index 3a79d55eb..000000000 --- a/apps/parsar-daemon/internal/agent/codex/skills.go +++ /dev/null @@ -1,52 +0,0 @@ -package codex - -import ( - "context" - "fmt" - "log/slog" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func effectiveAgentStateKey(req proto.PromptRequestPayload) string { - if strings.TrimSpace(req.AgentStateKey) != "" { - return req.AgentStateKey - } - if id := strings.TrimSpace(req.ConversationID); id != "" { - return "_legacy_conversation/" + id + "/codex" - } - if id := strings.TrimSpace(req.RunID); id != "" { - return "_legacy_run/" + id + "/codex" - } - return "" -} - -func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (string, error) { - rawSkills := req.AgentOptions["skills"] - root, err := agent.ManagedSkillsRoot("codex", req.AgentStateKey, req.ConversationID, req.RunID) - if err != nil { - return "", fmt.Errorf("codex: resolve managed skills root: %w", err) - } - result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) - if err != nil { - return "", fmt.Errorf("codex: install skills: %w", err) - } - for _, warning := range result.Warnings { - logger.Warn("codex: skill install warning", "run_id", req.RunID, "msg", warning) - } - if len(result.SkillDirs) == 0 { - return "", nil - } - return root, nil -} - -func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { - if len(roots) == 0 { - return nil - } - _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}) - return err -} diff --git a/apps/parsar-daemon/internal/agent/codex/skills_test.go b/apps/parsar-daemon/internal/agent/codex/skills_test.go deleted file mode 100644 index 89ae7a605..000000000 --- a/apps/parsar-daemon/internal/agent/codex/skills_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - - result := make(chan error, 1) - go func() { - result <- setSkillExtraRoots(context.Background(), client.JSONRPCClient, []string{"/managed/skills"}) - }() - - decoder := json.NewDecoder(server.FromClient) - var request struct { - ID string `json:"id"` - Method string `json:"method"` - Params SkillsExtraRootsSetParams `json:"params"` - } - if err := decoder.Decode(&request); err != nil { - t.Fatalf("decode request: %v", err) - } - if request.Method != "skills/extraRoots/set" { - t.Fatalf("method = %q", request.Method) - } - if len(request.Params.ExtraRoots) != 1 || request.Params.ExtraRoots[0] != "/managed/skills" { - t.Fatalf("params = %+v", request.Params) - } - response, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) - if _, err := server.ToClient.Write(append(response, '\n')); err != nil { - t.Fatalf("write response: %v", err) - } - if err := <-result; err != nil { - t.Fatalf("setSkillExtraRoots: %v", err) - } -} - -func TestPrepareManagedSkillsPrunesWhenPayloadOmitsSkills(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - stale := filepath.Join(home, "runtime", "codex", "state", "conv-1", "agent-1", "codex", "skills", "stale") - if err := os.MkdirAll(stale, 0o755); err != nil { - t.Fatal(err) - } - - root, err := prepareManagedSkills(context.Background(), nil, proto.PromptRequestPayload{ - AgentStateKey: "conv-1/agent-1/codex", - }) - if err != nil { - t.Fatalf("prepareManagedSkills: %v", err) - } - if root != "" { - t.Fatalf("root = %q, want empty", root) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale skill still exists: %v", err) - } -} - -func TestEffectiveAgentStateKeyFallsBackToConversation(t *testing.T) { - got := effectiveAgentStateKey(proto.PromptRequestPayload{ - ConversationID: "conv-legacy", - RunID: "run-ignored", - }) - if got != "_legacy_conversation/conv-legacy/codex" { - t.Fatalf("state key = %q", got) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go b/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go deleted file mode 100644 index cc278a604..000000000 --- a/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go +++ /dev/null @@ -1,64 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func subagentCoordination(raw json.RawMessage, actor string) (*proto.SubagentCoordinationPayload, error) { - var item struct { - subagentCollaboration - Model *string `json:"model"` - ReasoningEffort *string `json:"reasoningEffort"` - } - if json.Unmarshal(raw, &item) != nil { - return nil, errors.New("codex: invalid subagent coordination Item") - } - if item.Type != "collabAgentToolCall" { - return nil, nil - } - kinds := map[string]string{"spawnAgent": "create_subagent_call", "sendInput": "send_subagent_input_call", "wait": "wait_for_subagents_call", "closeAgent": "close_subagent_call", "resumeAgent": "resume_subagent_call"} - kind, ok := kinds[item.Tool] - if !ok { - return nil, errors.New("codex: unsupported subagent coordination tool") - } - status := observationStatus(item.Status, "after") - if item.Status == "inProgress" { - status = "in_progress" - } - return &proto.SubagentCoordinationPayload{ID: item.ID, Kind: kind, Status: status, ActorID: actor, Recipients: item.Receivers, Text: item.Prompt, Model: item.Model, ReasoningEffort: item.ReasoningEffort}, nil -} - -func (s *Session) publishSubagentCoordination(ctx context.Context, h subagentHistory, known map[string]bool) error { - root := s.currentThreadID() - - s.steering.mu.Lock() - rootTurn := s.steering.id - s.steering.mu.Unlock() - for _, turn := range h.Turns { - if h.ID == root && turn.ID != rootTurn { - continue - } - for _, raw := range turn.Items { - payload, err := subagentCoordination(raw, "") - if err != nil { - return err - } - if payload == nil { - continue - } - for _, id := range payload.Recipients { - if (id == root || !known[id]) && payload.Status != "failed" { - return errors.New("codex: coordination recipient has no verified subagent identity") - } - } - if err := s.sendSubagentFact(ctx, proto.TypeSubagentCoordination, turn.ID+":"+payload.ID, payload); err != nil { - return err - } - } - } - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_observations.go b/apps/parsar-daemon/internal/agent/codex/tool_observations.go deleted file mode 100644 index 96c3e68a7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_observations.go +++ /dev/null @@ -1,125 +0,0 @@ -package codex - -import ( - "encoding/json" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Raw fields bypass ThreadItem's legacy any fields to preserve structured values. -type toolObservationSource struct { - ThreadItem - Cwd *string `json:"cwd"` - Arguments json.RawMessage `json:"arguments"` - Changes json.RawMessage `json:"changes"` - Output *string `json:"aggregatedOutput"` - DurationMS *int64 `json:"durationMs"` - Result json.RawMessage `json:"result"` - Error json.RawMessage `json:"error"` - ContentItems *[]functionContent `json:"contentItems"` - Success *bool `json:"success"` - Action *proto.ToolWebSearchAction `json:"action"` -} - -func normalizeToolObservation(id, stage string, raw json.RawMessage) (*proto.ToolObservation, error) { - var n toolObservationSource - if err := json.Unmarshal(raw, &n); err != nil { - return nil, err - } - if n.ID != id || id == "" || (stage != "before" && stage != "after") { - return nil, errors.New("invalid native tool identity or stage") - } - out := &proto.ToolObservation{Status: observationStatus(n.Status, stage)} - switch n.Type { - case "commandExecution": - if n.Command == "" { - return nil, errors.New("missing command") - } - out.Kind, out.Command, out.Cwd, out.DurationMS = "command", n.Command, n.Cwd, n.DurationMS - if n.ExitCode != nil { - value := int64(*n.ExitCode) - out.ExitCode = &value - } - if n.Output != nil { - out.Output, _ = json.Marshal(*n.Output) - } - case "mcpToolCall": - if n.Server == "" || n.Tool == "" { - return nil, errors.New("missing MCP identity") - } - out.Kind, out.Server, out.Name = "mcp", n.Server, n.Tool - out.Arguments, out.Output, out.Error = n.Arguments, n.Result, n.Error - case "dynamicToolCall": - if n.Tool == "" { - return nil, errors.New("missing function identity") - } - out.Kind, out.Name, out.Arguments = "function", n.Tool, n.Arguments - if n.Namespace != "" { - out.Name = n.Namespace + "::" + n.Tool - } - if stage == "after" { - if n.Success != nil && !*n.Success { - out.Status = "failed" - } - if n.ContentItems != nil { - content := make([]proto.InputContent, 0, len(*n.ContentItems)) - for _, part := range *n.ContentItems { - var value proto.InputContent - switch part.Type { - case "inputText": - value = proto.InputContent{Type: "input_text", Text: part.Text} - case "inputImage": - value = proto.InputContent{Type: "input_image", ImageURL: part.ImageURL} - default: - return nil, errors.New("unsupported function result content") - } - content = append(content, value) - } - if err := (proto.FunctionResultPayload{Content: content}).ValidateContent(); err != nil { - return nil, err - } - out.Content = &content - } - } - case "fileChange": - out.Kind, out.Name = "function", "apply_patch" - changes := n.Changes - if len(changes) == 0 { - changes = json.RawMessage("null") - } - out.Arguments, _ = json.Marshal(struct { - Changes json.RawMessage `json:"changes"` - }{changes}) - case "webSearch": - out.Kind, out.Action = "web_search", n.Action - if out.Action != nil { - switch out.Action.Type { - case "openPage": - out.Action.Type = "open_page" - case "findInPage": - out.Action.Type = "find_in_page" - case "search", "open_page", "find_in_page", "other": - default: - return nil, errors.New("unsupported web action") - } - } - default: - return nil, errors.New("unsupported native tool observation") - } - return out, nil -} - -func observationStatus(native, stage string) string { - if stage == "before" { - return "in_progress" - } - switch native { - case "", "completed": - return "completed" - case "failed", "declined": - return "failed" - default: - return "incomplete" - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/unsupported.go b/apps/parsar-daemon/internal/agent/codex/unsupported.go deleted file mode 100644 index ea120f48b..000000000 --- a/apps/parsar-daemon/internal/agent/codex/unsupported.go +++ /dev/null @@ -1,42 +0,0 @@ -package codex - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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 -} - -func (s *Prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/parsar-daemon/internal/agent/codex/unsupported_test.go b/apps/parsar-daemon/internal/agent/codex/unsupported_test.go deleted file mode 100644 index 49dbad31a..000000000 --- a/apps/parsar-daemon/internal/agent/codex/unsupported_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package codex - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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), (*Prepared)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } - for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/version.go b/apps/parsar-daemon/internal/agent/codex/version.go deleted file mode 100644 index 06ae5fa95..000000000 --- a/apps/parsar-daemon/internal/agent/codex/version.go +++ /dev/null @@ -1,60 +0,0 @@ -package codex - -import ( - "bytes" - "context" - "errors" - "fmt" - "os/exec" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" -) - -// InstallURL points operators at the Codex install instructions when -// 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() } - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary -// cannot be located on PATH. Callers use errors.Is to distinguish an -// install problem from a present-but-broken CLI. -var ErrCLINotFound = errors.New("codex CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). Matches -// the CheckCLIAvailable signature of the claudecode and opencode adapters -// so connect.go's preflight loop treats every engine uniformly. -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - if strings.TrimSpace(binary) == "" { - binary = defaultBinary() - } - if _, lookErr := exec.LookPath(binary); lookErr != nil { - return "", fmt.Errorf("%w: %s", ErrCLINotFound, binary) - } - - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, binary, "--version") - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() - } - return "", fmt.Errorf("codex --version failed: %s", msg) - } - out := strings.TrimSpace(stdout.String()) - if i := strings.IndexByte(out, '\n'); i >= 0 { - out = out[:i] - } - if out == "" { - return "", fmt.Errorf("codex --version returned empty output") - } - return out, nil -} diff --git a/apps/parsar-daemon/internal/agent/configuration_test.go b/apps/parsar-daemon/internal/agent/configuration_test.go deleted file mode 100644 index 75a44ec39..000000000 --- a/apps/parsar-daemon/internal/agent/configuration_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package agent_test - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" -) - -func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { - registry := agent.NewRegistry() - configuration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "responses"}}} - calls := 0 - expected := errors.New("native entry reached") - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, configuration, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - calls++ - return nil, expected - }) - registry.RegisterExecutor("fixture", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - calls++ - return nil, expected - }) - registry.RegisterPreparation("fixture", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - calls++ - return nil, expected - }) - configuration.Providers[0].Protocol = "anthropic" - copy, err := registry.Configuration("fixture") - if err != nil { - t.Fatal(err) - } - copy.Providers[0].Protocol = "anthropic" - factory, _ := registry.Resolve("fixture") - executor, _ := registry.ResolveExecutor("fixture") - preparation, _ := registry.ResolvePreparation("fixture") - entries := []func(proto.PromptRequestPayload) error{ - func(req proto.PromptRequestPayload) error { _, err := factory(t.Context(), req, nil); return err }, - func(req proto.PromptRequestPayload) error { _, err := executor(t.Context(), req); return err }, - func(req proto.PromptRequestPayload) error { _, err := preparation(t.Context(), req); return err }, - } - for _, entry := range entries { - for _, options := range []map[string]any{ - {"model": nil}, - {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"harness_config": map[string]any{"unknown": "private-sentinel"}}, - } { - before := calls - err := entry(proto.PromptRequestPayload{AgentOptions: options}) - if err == nil || errors.Is(err, expected) || calls != before || strings.Contains(err.Error(), "private-sentinel") { - t.Fatal("invalid configuration reached native entry or leaked values") - } - } - if err := entry(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) { - t.Fatal("bound declaration was lost or mutated", err) - } - } - if calls != 3 { - t.Fatal("unexpected native calls", calls) - } - if _, err := registry.Configuration("missing"); err == nil { - t.Fatal("undeclared configuration inferred") - } -} - -func TestRegistryRejectsInvalidConfigurationDeclaration(t *testing.T) { - defer func() { - if recover() == nil { - t.Fatal("invalid configuration registered") - } - }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "unknown"}}}, stubFactory("fixture")) -} diff --git a/apps/parsar-daemon/internal/agent/installroot/probe.go b/apps/parsar-daemon/internal/agent/installroot/probe.go deleted file mode 100644 index b0a12fc3d..000000000 --- a/apps/parsar-daemon/internal/agent/installroot/probe.go +++ /dev/null @@ -1,35 +0,0 @@ -package installroot - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "io" - "strings" - "time" -) - -// Native diagnostics are deliberately discarded: dependencies may echo their -// environment. Readiness is separate from model credentials and a live Turn. -func Probe(parent context.Context, binary string, args, env []string, dir string) (string, error) { - ctx, cancel := context.WithTimeout(parent, 25*time.Second) - defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return "", errors.New("native component failed to start") - } - defer p.Cancel() - done := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() - raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) - if err != nil || len(raw) > 64*1024 { - p.Cancel() - } - _, _ = io.Copy(io.Discard, p.Stdout) - <-done - waitErr := p.Wait() - if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { - return "", errors.New("native component compatibility check failed") - } - return strings.TrimSpace(string(raw)), nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/connection.go b/apps/parsar-daemon/internal/agent/mcode/connection.go deleted file mode 100644 index 420155530..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/connection.go +++ /dev/null @@ -1,146 +0,0 @@ -package mcode - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strconv" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" -) - -// connection is the process and ACP owner. It keeps draining while no Turn owns -// output; RPC identities remain unique across every Turn on this connection. -type connection struct { - process *clirunner.Process - exited chan struct{} - exitErr error - writeMu sync.Mutex - transportMu sync.Mutex - nextID int - responses map[string]chan rpcFrame - current *Session -} - -func (c *connection) read() { - defer close(c.exited) - stderrDone := make(chan struct{}) - go func() { defer close(stderrDone); _, _ = io.Copy(io.Discard, c.process.Stderr) }() - scanner := bufio.NewScanner(c.process.Stdout) - scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - var readErr error - for scanner.Scan() { - var frame rpcFrame - if json.Unmarshal(scanner.Bytes(), &frame) != nil { - readErr = fmt.Errorf("mcode: malformed ACP response") - c.process.Cancel() - break - } - c.transportMu.Lock() - response, owner := c.responses[string(frame.ID)], c.current - c.transportMu.Unlock() - if frame.Method == "" && response != nil { - select { - case response <- frame: - default: - } - continue - } - if owner == nil { - // A late request has no Turn authority. Never hold it for a successor. - if frame.Method != "" && len(frame.ID) > 0 { - if err := c.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32601, Message: "No active Turn"}}); err != nil { - readErr = err - c.process.Cancel() - break - } - } - continue - } - select { - case owner.frames <- frame: - case <-owner.finished: - case <-c.process.Context().Done(): - } - } - if scanner.Err() != nil { - readErr = fmt.Errorf("mcode: ACP stream read failed") - c.process.Cancel() - } - waitErr := c.process.Wait() - <-stderrDone - if readErr != nil { - c.exitErr = readErr - } else { - c.exitErr = waitErr - } -} - -func (c *connection) write(frame rpcFrame) error { - c.writeMu.Lock() - defer c.writeMu.Unlock() - return json.NewEncoder(c.process.Stdin).Encode(frame) -} - -func (c *connection) reserveResponse() (string, chan rpcFrame) { - c.transportMu.Lock() - defer c.transportMu.Unlock() - c.nextID++ - id := strconv.Itoa(c.nextID) - reply := make(chan rpcFrame, 1) - c.responses[id] = reply - return id, reply -} - -func (c *connection) removeResponse(id string) { - c.transportMu.Lock() - delete(c.responses, id) - c.transportMu.Unlock() -} - -func (c *connection) setCurrent(s *Session) { - c.transportMu.Lock() - c.current = s - c.transportMu.Unlock() -} - -// An ordered ACP control response fences notifications left in the pipe by the -// preceding prompt. No Turn is attached while this barrier drains them. -func (c *connection) barrier(ctx context.Context, session, model string) error { - id, reply := c.reserveResponse() - defer c.removeResponse(id) - raw, _ := json.Marshal(map[string]string{"sessionId": session, "configId": "model", "value": model}) - err := c.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "session/set_config_option", Params: raw}) - if err != nil { - return err - } - select { - case frame := <-reply: - if frame.Error != nil { - return fmt.Errorf("mcode: native readiness barrier failed") - } - return nil - case <-c.exited: - return fmt.Errorf("mcode: native process exited") - case <-ctx.Done(): - return ctx.Err() - } -} - -// Drain the deadline callback before releasing the caller's Turn/start guard; -// an old writer deadline must never kill a subsequent owner. -func (c *connection) writeContext(ctx context.Context, frame rpcFrame) error { - callbackDone := make(chan struct{}) - stop := context.AfterFunc(ctx, func() { - defer close(callbackDone) - c.process.Cancel() - }) - err := c.write(frame) - if !stop() { - <-callbackDone - } - return err -} diff --git a/apps/parsar-daemon/internal/agent/mcode/contracts.go b/apps/parsar-daemon/internal/agent/mcode/contracts.go deleted file mode 100644 index 6fac6506c..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/contracts.go +++ /dev/null @@ -1,27 +0,0 @@ -package mcode - -import "github.com/MiniMax-AI-Dev/parsar/apps/parsar-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.Steerer = (*Session)(nil) - _ agent.FunctionResultSubmitter = (*Session)(nil) - _ agent.PermissionResponder = (*Session)(nil) - _ agent.UserChoiceResponder = (*Session)(nil) - _ agent.WorkspaceReader = (*Session)(nil) - _ agent.WorkspaceDirectoryLister = (*Session)(nil) - _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) - _ agent.WorkspaceReader = (*executor)(nil) - _ agent.WorkspaceDirectoryLister = (*executor)(nil) - _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) -) diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go deleted file mode 100644 index cfe83cdc1..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/executor.go +++ /dev/null @@ -1,176 +0,0 @@ -package mcode - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -var errTurnCancelled = errors.New("mcode: Turn cancelled before submission") - -type executor struct { - mu sync.Mutex - connection *connection - req proto.PromptRequestPayload - opts launchOptions - nativeSession, model string - active *Session - starting, closed, invalid bool -} - -var _ agent.Executor = (*executor)(nil) -var _ agent.Turn = (*Session)(nil) - -// NewExecutorFactory fixes the deployment workspace once; nil selects none. -func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { - var frozen *WorkspaceConfig - if config != nil { - value := *config - value.AllowedDomains = append([]string(nil), config.AllowedDomains...) - frozen = &value - } - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if ctx == nil { - ctx = context.Background() - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") - } - var err error - var opts launchOptions - binary := defaultBinary() - if frozen == nil { - opts, err = prepareOptions(ctx, req) - } else { - binary = frozen.Binary - opts, err = prepareWorkspaceOptions(ctx, *frozen, req) - } - if err != nil { - return nil, err - } - resource, err := newExecutor(ctx, req, opts, binary) - if resource == nil { - return nil, err - } - return resource, err - } -} - -func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { - bootstrap, err := launch(ctx, req, opts, binary, nil) - if err != nil { - return nil, err - } - e := &executor{connection: bootstrap.connection, req: req, opts: opts} - err = bootstrap.prepareNative() - e.connection.setCurrent(nil) - close(bootstrap.finished) - if err == nil { - err = ctx.Err() - } - if err != nil { - cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if closeErr := e.Close(cleanup); closeErr != nil { - return e, err - } - return nil, err - } - e.nativeSession, e.model = bootstrap.sessionID, bootstrap.nativeModel - return e, nil -} - -func (e *executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if ctx == nil { - ctx = context.Background() - } - if strings.TrimSpace(runID) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { - return nil, fmt.Errorf("mcode: Turn requires live context, identity, input and output") - } - text, err := input.TextOnly() - if err != nil { - return nil, err - } - e.mu.Lock() - if e.closed || e.invalid || e.starting || e.active != nil { - e.mu.Unlock() - return nil, fmt.Errorf("mcode: Executor is unavailable") - } - e.starting = true - e.mu.Unlock() - - // No model input is sent and no output channel retained until this fence. - barrier, cancel := context.WithTimeout(ctx, 60*time.Second) - err = e.connection.barrier(barrier, e.nativeSession, e.model) - cancel() - e.mu.Lock() - defer e.mu.Unlock() - e.starting = false - if err != nil || e.closed || ctx.Err() != nil { - if err != nil { - e.invalid = true - return nil, err - } - return nil, fmt.Errorf("mcode: Executor closed before input") - } - select { - case <-e.connection.exited: - e.invalid = true - return nil, fmt.Errorf("mcode: native process exited") - default: - } - req := e.req - req.RunID, req.Input = runID, proto.TextInput(text) - s := newTurnSession(e.connection.process.Context(), req, e.opts, e.connection, out) - s.executor, s.sessionID, s.nativeModel = e, e.nativeSession, e.model - s.settled, s.inputDone = make(chan struct{}), make(chan struct{}) - s.outputContext, s.outputCancel = context.WithCancel(context.Background()) - e.active = s - e.connection.setCurrent(s) - go s.runExecutorTurn() - go func() { - select { - case <-ctx.Done(): - cancellation, stop := context.WithTimeout(context.Background(), 10*time.Second) - defer stop() - _ = s.Cancel(cancellation) - case <-s.settled: - } - }() - return s, nil -} - -// Close keeps the native owner reachable until process, pipes and active Turn -// settlement all finish. Repeating it waits on those same owners. -func (e *executor) Close(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - e.mu.Lock() - e.closed = true - current := e.active - if current != nil { - current.outputCancel() - } - e.connection.process.Cancel() - e.mu.Unlock() - select { - case <-e.connection.exited: - case <-ctx.Done(): - return ctx.Err() - } - if current != nil { - select { - case <-current.settled: - case <-ctx.Done(): - return ctx.Err() - } - } - return nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go deleted file mode 100644 index 5340c3f9f..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// This opt-in test makes real model calls and uses an isolated native home. -// Provider options are read from a private file and never included in failures. -func TestNativeMCodeExecutorReuse(t *testing.T) { - binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") - if binary == "" || options == "" { - t.Skip("native executable and private provider options required") - } - ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) - defer cancel() - if version, err := CheckCLIAvailable(ctx, binary); err != nil || version != SupportedVersion { - t.Fatal("pinned native version verification failed") - } - raw, err := os.ReadFile(options) - if err != nil { - t.Fatal("private provider options unavailable") - } - req := executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" - if json.Unmarshal(raw, &req.AgentOptions) != nil { - t.Fatal("invalid private provider options") - } - value, err := NewExecutorFactory(nil)(ctx, req) - if err != nil { - t.Fatal("native Executor preparation failed") - } - e := value.(*executor) - defer func() { - cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) - defer stop() - if e.Close(cleanup) != nil { - t.Error("native owner cleanup failed") - } - }() - pid, nativeID := e.connection.process.Cmd.Process.Pid, e.nativeSession - assertOwner := func() { - t.Helper() - if e.connection.process.Cmd.Process.Pid != pid || e.nativeSession != nativeID { - t.Fatal("native owner changed") - } - select { - case <-e.connection.exited: - t.Fatal("native process exited") - default: - } - } - run := func(id, prompt string) agent.Turn { - t.Helper() - out := make(chan proto.Envelope, 256) - turn, startErr := e.StartTurn(ctx, id, proto.TextInput(prompt), out) - if startErr != nil || turn == nil { - t.Fatal("native Turn admission failed") - } - content := "" - doneCount := 0 - for event := range out { - if event.ID != id { - t.Fatal("event crossed Turn boundary") - } - switch event.Type { - case proto.TypeError: - t.Fatal("native Turn reported an error") - case proto.TypeDone: - var done proto.DonePayload - if json.Unmarshal(event.Payload, &done) != nil { - t.Fatal("invalid completion") - } - content = done.Content - doneCount++ - } - } - settlement, settleErr := turn.AwaitSettlement(ctx) - if settleErr != nil || !settlement.Reusable { - t.Fatal("normal Turn did not establish reusable settlement") - } - if doneCount != 1 || !strings.Contains(content, "ORCHID-72-BLUE") { - t.Fatal("native conversation did not retain the private test marker") - } - assertOwner() - t.Logf("Verified marker recall and retained native owner for %s", id) - return turn - } - first := run("first", "Remember the exact marker ORCHID-72-BLUE for this conversation. Reply with only that marker.") - run("second", "What exact marker did I ask you to remember? Reply with only that marker.") - out := make(chan proto.Envelope, 256) - interrupted, err := e.StartTurn(ctx, "cancelled", proto.TextInput("Produce 2000 numbered lines now. Each line must contain a different sentence about rain. Start immediately at line 1 and continue without stopping or summarizing."), out) - if err != nil || interrupted == nil { - t.Fatal("cancellation Turn admission failed") - } - streaming := false - for !streaming { - select { - case event, ok := <-out: - if !ok || event.Type == proto.TypeDone || event.Type == proto.TypeError { - t.Fatal("Turn ended before live cancellation could be tested") - } - if event.Type == proto.TypeDelta { - streaming = true - } - case <-ctx.Done(): - t.Fatal("native stream did not begin before deadline") - } - } - select { - case <-interrupted.(*Session).finished: - t.Fatal("cancellation was not in flight") - default: - } - staleCtx, staleStop := context.WithTimeout(ctx, 5*time.Second) - if first.Cancel(staleCtx) != nil { - staleStop() - t.Fatal("stale cancellation failed") - } - staleStop() - interrupted.(*Session).mu.Lock() - wasCancelled := interrupted.(*Session).cancelled - interrupted.(*Session).mu.Unlock() - if wasCancelled { - t.Fatal("stale cancellation targeted current Turn") - } - stopCtx, stop := context.WithTimeout(ctx, 30*time.Second) - cancelErr := interrupted.Cancel(stopCtx) - settlement, settleErr := interrupted.AwaitSettlement(stopCtx) - stop() - if settleErr != nil || cancelErr != nil { - t.Fatal("native cancellation did not settle", cancelErr, settleErr) - } - if !settlement.Reusable { - t.Log("Native cancellation cannot establish reusable settlement; successor must use recovery") - successor := make(chan proto.Envelope, 1) - next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TextInput("must not execute"), successor) - if next != nil || nextErr == nil { - t.Fatal("unsettled native owner accepted a successor") - } - close(successor) - cleanup, cleanupStop := context.WithTimeout(ctx, 15*time.Second) - if e.Close(cleanup) != nil { - cleanupStop() - t.Fatal("invalid native owner did not close") - } - cleanupStop() - req.AgentSessionID = nativeID - recovered, recoverErr := NewExecutorFactory(nil)(ctx, req) - if recoverErr != nil || recovered == nil { - t.Fatal("exact native history recovery failed") - } - e = recovered.(*executor) - if e.nativeSession != nativeID || e.connection.process.Cmd.Process.Pid == pid { - t.Fatal("recovery did not replace the process while retaining native history") - } - pid = e.connection.process.Cmd.Process.Pid - run("recovery", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") - t.Log("Verified exact history continuation in a replacement native owner after nonreusable cancellation") - return - } - if cancelErr != nil { - t.Fatal("settled cancellation reported an error") - } - assertOwner() - run("continuation", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") - t.Log("Verified native process and session reuse across two normal Turns, active cancellation, and continuation") -} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_test.go deleted file mode 100644 index 2edf8eecd..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/executor_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { - t.Helper() - config, req, record := workspaceFixture(t) - req.ReleaseOnCompletion = false - script, err := os.ReadFile(config.Binary) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(config.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER="+scenario, 1)), 0700); err != nil { - t.Fatal(err) - } - var factory agent.ExecutorFactory - if workspace { - factory = NewExecutorFactory(&config) - } else { - req = executionRequest(t) - req.ReleaseOnCompletion = false - req.RunID, req.Input, req.ConversationID = "", nil, "" - t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) - factory = NewExecutorFactory(nil) - } - value, err := factory(t.Context(), req) - if err != nil { - t.Fatal(err) - } - e := value.(*executor) - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(cleanup); err != nil { - t.Error(err) - } - }) - return e, record -} - -func runExecutorFixtureTurn(t *testing.T, e *executor, id, text string) (*Session, []proto.Envelope) { - t.Helper() - out := make(chan proto.Envelope, 256) - turn, err := e.StartTurn(t.Context(), id, proto.TextInput(text), out) - if err != nil { - t.Fatal(err) - } - var events []proto.Envelope - for event := range out { - events = append(events, event) - } - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - settlement, err := turn.AwaitSettlement(ctx) - if err != nil || !settlement.Reusable { - t.Fatalf("settlement = %+v, %v", settlement, err) - } - return turn.(*Session), events -} - -func TestExecutorReusesNativeOwnerWithFreshTurnsAndIdleDrain(t *testing.T) { - for _, workspace := range []bool{false, true} { - t.Run(map[bool]string{false: "none", true: "workspace"}[workspace], func(t *testing.T) { - e, record := executorFixture(t, "executor-reuse", workspace) - pid := e.connection.process.Cmd.Process.Pid - first, _ := runExecutorFixtureTurn(t, e, "first", "one") - second, events := runExecutorFixtureTurn(t, e, "second", "two") - if first == second || e.connection.process.Cmd.Process.Pid != pid { - t.Fatal("Turn reused mutable state or rebuilt the process") - } - text, tools, done := "", 0, 0 - for _, event := range events { - if event.ID != "second" { - t.Fatal("event escaped its Turn", event.ID) - } - switch event.Type { - case proto.TypeError: - t.Fatalf("execution failed: %s", event.Payload) - case proto.TypeDelta: - var delta proto.DeltaPayload - _ = json.Unmarshal(event.Payload, &delta) - text += delta.Delta - if delta.Sequence != 1 { - t.Fatal("sequence carried over from old Turn") - } - case proto.TypeToolCall: - tools++ - case proto.TypeDone: - done++ - } - } - if text != "two" || tools != 2 || done != 1 { - t.Fatalf("cross-Turn state: text=%q tools=%d done=%d", text, tools, done) - } - raw, _ := os.ReadFile(record) - if strings.Count(string(raw), "initialize\n") != 1 || strings.Count(string(raw), "session/new\n") != 1 || strings.Count(string(raw), "session/prompt\n") != 2 { - t.Fatalf("native owner was recreated: %s", raw) - } - select { - case <-e.connection.exited: - t.Fatal("completion killed native owner") - default: - } - }) - } -} - -func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing.T) { - for _, disabled := range []bool{true, false} { - t.Run(map[bool]string{true: "single-agent", false: "subagents"}[disabled], func(t *testing.T) { - e, record := executorFixture(t, "executor-cancel", true) - first, _ := runExecutorFixtureTurn(t, e, "first", "one") - e.req.DisableSubagents = disabled - if !disabled { - // A terminal native history record still cannot prove Bash cleanup. - dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") - body := "#!/bin/sh\nprintf '%s\\n' '{\"version\":1,\"complete\":true,\"rootSessionId\":\"native-1\",\"sessions\":[{\"id\":\"native-1\",\"turns\":[{\"id\":\"root-turn\",\"status\":\"aborted\"}]}]}'\n" - for name, data := range map[string]string{node: body, bridge: "", filepath.Join(dir, "subagent-snapshot.mjs"): ""} { - if err := os.WriteFile(name, []byte(data), 0700); err != nil { - t.Fatal(err) - } - } - t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) - } - out := make(chan proto.Envelope, 32) - second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) - if err != nil { - t.Fatal(err) - } - select { - case event := <-out: - if event.Type != proto.TypeDelta { - t.Fatal(event.Type) - } - case <-time.After(3 * time.Second): - t.Fatal("second Turn did not start") - } - if err = first.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(record) - if strings.Contains(string(raw), "session/cancel") { - t.Fatal("late cancellation targeted the new Turn") - } - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err = second.Cancel(ctx); err != nil { - t.Fatal("stopped nonreusable owner reported cancellation failure", err) - } - settlement, err := second.AwaitSettlement(ctx) - if err != nil || settlement.Reusable { - t.Fatal(settlement, err) - } - select { - case <-e.connection.exited: - default: - t.Fatal("nonreusable settlement preceded native process exit") - } - if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("cancel lost settled outcome", got) - } - for range out { - } - thirdOut := make(chan proto.Envelope, 1) - third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) - if third != nil || err == nil { - t.Fatal("unproven native owner admitted a successor") - } - close(thirdOut) - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/cancel\n") != 1 || strings.Count(string(raw), "initialize\n") != 1 { - t.Fatalf("cancellation replaced owner: %s", raw) - } - }) - } -} - -func TestExecutorStartFailureOwnership(t *testing.T) { - t.Run("validation", func(t *testing.T) { - e, record := executorFixture(t, "executor-reuse", true) - out := make(chan proto.Envelope, 1) - turn, err := e.StartTurn(t.Context(), "", proto.TextInput("invalid"), out) - if err == nil || turn != nil { - t.Fatal("invalid Start acquired output") - } - close(out) - raw, _ := os.ReadFile(record) - if strings.Contains(string(raw), "session/prompt") { - t.Fatal("invalid input was sent") - } - runExecutorFixtureTurn(t, e, "healthy", "valid") - }) - t.Run("before input", func(t *testing.T) { - e, record := executorFixture(t, "executor-preexit", true) - out := make(chan proto.Envelope, 1) - turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) - if err == nil || turn != nil { - t.Fatal("failed readiness retained caller output") - } - close(out) - raw, _ := os.ReadFile(record) - if strings.Contains(string(raw), "session/prompt") { - t.Fatal("pre-input failure sent input") - } - }) - t.Run("after submission", func(t *testing.T) { - e, record := executorFixture(t, "executor-exit", true) - out := make(chan proto.Envelope, 32) - turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) - if err != nil || turn == nil { - t.Fatal("submitted Turn lost ownership", err) - } - for range out { - } - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - settlement, err := turn.AwaitSettlement(ctx) - if err == nil || settlement.Reusable { - t.Fatal("native exit lost its settlement error", settlement, err) - } - raw, _ := os.ReadFile(record) - if strings.Count(string(raw), "session/prompt\n") != 1 { - t.Fatal("uncertain input was replayed") - } - rejected := make(chan proto.Envelope) - again, err := e.StartTurn(t.Context(), "again", proto.TextInput("again"), rejected) - if again != nil || err == nil { - t.Fatal("invalid owner accepted another Turn") - } - close(rejected) - }) -} - -func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { - e, _ := executorFixture(t, "executor-reuse", true) - cancelled, cancel := context.WithCancel(t.Context()) - cancel() - _ = e.Close(cancelled) - ctx, stop := context.WithTimeout(t.Context(), 5*time.Second) - defer stop() - if err := e.Close(ctx); err != nil { - t.Fatal(err) - } - select { - case <-e.connection.exited: - default: - t.Fatal("Close lost native cleanup ownership") - } -} - -func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { - config, req, _ := workspaceFixture(t) - req.ReleaseOnCompletion = false - if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - value, err := NewExecutorFactory(&config)(t.Context(), req) - if err == nil || value != nil { - t.Fatalf("settled preparation failure returned owner: nil=%t error=%v", value == nil, err) - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go deleted file mode 100644 index ed81a3cc6..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go +++ /dev/null @@ -1,151 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (s *Session) runExecutorTurn() { - err := s.captureSubagentBaseline() - if err == nil { - err = s.executePrompt() - } - if errors.Is(err, errTurnCancelled) { - err = nil - } - s.mu.Lock() - s.closing, s.steeringReady = true, false - close(s.inputDone) - s.mu.Unlock() - // Written steering requests retain their original receipt owner even after - // prompt completion. No successor starts until all callers have settled. - s.operations.Wait() - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady { - childErr := s.settleSubagents() - s.mu.Lock() - s.subagentSettlementError = childErr - s.mu.Unlock() - if childErr != nil { - err = childErr - } - } - reusable := err == nil && s.process.Context().Err() == nil - if len(s.tools) > 0 { - reusable = false - } - s.finishEnvironmentMCP() - s.mu.Lock() - // ACP and native history cancellation do not prove detached tool cleanup. - // Retire the owner and settle its workers before acknowledging cancellation. - if s.cancelled || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { - reusable = false - } - if s.inputUncertain && err == nil { - err = fmt.Errorf("mcode: native input outcome is unknown") - } - metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} - s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} - outcome := s.outcome - s.permissions, s.questions = map[string]pendingPermission{}, map[string]pendingQuestion{} - s.mu.Unlock() - if !reusable { - // Unknown quiescence invalidates this owner. A successful settlement - // still proves its native process group and pipes have stopped. - s.process.Cancel() - <-s.exited - } - if err != nil { - s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) - } - s.emit(proto.TypeDone, outcome) - close(s.out) - close(s.finished) - s.executor.mu.Lock() - s.connection.setCurrent(nil) - if !reusable { - s.executor.invalid = true - } - s.executor.active = nil - s.settlement = agent.TurnSettlement{Reusable: reusable} - s.settlementErr = err - if !reusable { - s.settlement.Reason = "native Turn did not establish reusable settlement" - } - close(s.settled) - s.executor.mu.Unlock() - s.outputCancel() -} - -func (s *Session) captureSubagentBaseline() error { - if !s.req.StrictResume || s.req.DisableSubagents { - return nil - } - snapshot, err := s.readSubagents(s.ctx) - s.subagentHistoryReady = err == nil - s.previousNativeTurns = map[string]bool{} - for _, session := range snapshot.Sessions { - if session.ID == s.sessionID { - for _, turn := range session.Turns { - s.previousNativeTurns[turn.ID] = true - } - } - } - return err -} - -func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - if s.settled == nil { - return agent.TurnSettlement{}, fmt.Errorf("mcode: Turn has no Executor owner") - } - select { - case <-s.settled: - return s.settlement, s.settlementErr - case <-ctx.Done(): - return agent.TurnSettlement{}, ctx.Err() - } -} - -func (s *Session) cancelTurn(ctx context.Context) error { - e := s.executor - e.mu.Lock() - if e.active != s { - e.mu.Unlock() - _, err := s.AwaitSettlement(ctx) - return err - } - s.mu.Lock() - first := !s.cancelled && !s.closing - s.cancelled = true - if first { - s.operations.Add(1) - } - s.mu.Unlock() - // Cancellation releases event backpressure but does not cancel native owner - // context. After native settlement, cancellation retires and drains the owner. - s.outputCancel() - e.mu.Unlock() - var err error - if first { - raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) - err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) - } - if first { - if err == nil && s.req.StrictResume && !s.req.DisableSubagents { - err = s.stopSubagents(ctx) - } - if err != nil { - s.markInputUncertain() - } - s.operations.Done() - } - if err != nil { - return err - } - _, err = s.AwaitSettlement(ctx) - return err -} diff --git a/apps/parsar-daemon/internal/agent/mcode/harness_config_test.go b/apps/parsar-daemon/internal/agent/mcode/harness_config_test.go deleted file mode 100644 index a8efcc9c7..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/harness_config_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package mcode - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "testing" -) - -func TestNativeConfigDoesNotPretendToSupportParameters(t *testing.T) { - req := testRequest(t) - req.AgentOptions["harness_config"] = map[string]any{} - if _, err := prepareOptions(context.Background(), req); err != nil { - t.Fatal(err) - } - req.AgentOptions["harness_config"] = map[string]any{"temperature": 0.5} - if _, err := prepareOptions(context.Background(), req); err != harnessconfig.ErrHarnessConfig { - t.Fatalf("unsupported parameter accepted: %v", err) - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/installation.go b/apps/parsar-daemon/internal/agent/mcode/installation.go deleted file mode 100644 index eff3a77e7..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/installation.go +++ /dev/null @@ -1,28 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" - "path/filepath" - "runtime" -) - -func Installation() agent.Installation { - return agent.Installation{AgentKind: "mcode", Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, - Environment: func(dir, node string) map[string]string { - return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "bridge.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} - }, - 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) - if err != nil || got != SupportedVersion { - return fmt.Errorf("MiniMax installation is incompatible") - } - raw, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "check.mjs")}, env, dir) - if err != nil || ValidateWorkspaceReadiness([]byte(raw)) != nil { - return fmt.Errorf("MiniMax companion is unavailable or incompatible; use the current native distribution and verify Bash") - } - return nil - }} -} diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go deleted file mode 100644 index 385d39e32..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/options.go +++ /dev/null @@ -1,250 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - harnessconfiguration "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/mcode" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -type launchOptions struct { - Dir, DataDir, Model string - Env []string - MCP []map[string]any -} - -func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { - return prepareOptionsWithSkills(ctx, req, true) -} - -func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { - var result launchOptions - if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { - return result, err - } - if req.StrictResume { - if err := validateExecutionRequest(req); err != nil { - return result, err - } - } - if req.Input.HasImages() { - return result, fmt.Errorf("mcode: ACP does not support attachments") - } - root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) - if err != nil { - return result, err - } - result.DataDir = filepath.Dir(root) - result.Dir, err = workDir(req.WorkDir, filepath.Join(result.DataDir, "workspace")) - if err != nil { - return result, err - } - if err := os.MkdirAll(result.DataDir, 0o700); err != nil { - return result, err - } - if req.WorkDir == "" { - if err := os.MkdirAll(result.Dir, 0o700); err != nil { - return result, err - } - } - if managedSkills { - installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) - if err != nil { - return result, err - } - if len(installed.Warnings) > 0 { - return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") - } - } - opts := req.AgentOptions - prompt := optionString(opts, "system_prompt") - if override := optionString(opts, "override_system_prompt"); override != "" { - prompt = override - } - if len(prompt) > 32*1024 { - return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") - } - if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { - return result, err - } - config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} - result.Model = optionString(opts, "model") - if result.Model == "" { - return result, fmt.Errorf("mcode: model is required") - } - provider, err := modelProviderConfig(opts["model_provider"], result.Model) - if err != nil { - return result, err - } - config["custom_provider"] = map[string]any{"oac": provider} - if req.StrictResume { - configureTextExecution(config) - if !req.DisableSubagents { - config["agents"] = map[string]any{"default": map[string]any{ - "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, - "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, - "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, - }} - } - } - mode := optionString(opts, "mode") - if mode == "" { - mode = "auto" - } - if mode != "auto" && mode != "default" && mode != "bypassPermissions" { - return result, fmt.Errorf("mcode: unsupported permission mode") - } - config["permissionMode"] = mode - data, err := json.Marshal(config) - if err != nil { - return result, err - } - if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { - return result, err - } - result.Env = append([]string{}, os.Environ()...) - if req.StrictResume { - result.Env = append(executionEnvironment(), "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") - if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { - return result, err - } - if !req.DisableSubagents { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) - } else { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") - } - } - if raw := opts["env"]; raw != nil && !req.StrictResume { - env, ok := raw.(map[string]any) - if !ok { - return result, fmt.Errorf("mcode: env must be an object") - } - for key, rawValue := range env { - value, ok := rawValue.(string) - if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { - return result, fmt.Errorf("mcode: invalid environment entry") - } - result.Env = append(result.Env, key+"="+value) - } - } - // The adapter owns the native state location, including after cold resume. - result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) - if req.StrictResume { - result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) - } - result.MCP, err = mcpServers(opts["mcp_servers"]) - return result, err -} - -func workDir(raw, fallback string) (string, error) { - if raw == "" { - return fallback, nil - } - if strings.HasPrefix(raw, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - raw = filepath.Join(home, raw[2:]) - } - if !filepath.IsAbs(raw) { - return "", fmt.Errorf("mcode: working directory must be absolute or start with ~/") - } - return filepath.Clean(raw), nil -} - -func optionString(options map[string]any, key string) string { - value, _ := options[key].(string) - return value -} - -func mcpServers(raw any) ([]map[string]any, error) { - result := []map[string]any{} - if raw == nil { - return result, nil - } - servers, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: mcp_servers must be an object") - } - names := make([]string, 0, len(servers)) - for name := range servers { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - entry, ok := servers[name].(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: invalid MCP server %q", name) - } - server := map[string]any{"name": name} - if url := optionString(entry, "url"); url != "" { - kind := optionString(entry, "type") - if kind == "" { - kind = "http" - } - if kind != "http" && kind != "sse" { - return nil, fmt.Errorf("mcode: unsupported MCP transport %q", kind) - } - server["type"] = kind - server["url"] = url - headers, err := namedValues(entry["headers"]) - if err != nil { - return nil, err - } - server["headers"] = headers - } else { - command := optionString(entry, "command") - if command == "" { - return nil, fmt.Errorf("mcode: MCP server %q needs command or URL", name) - } - server["command"] = command - args := entry["args"] - if args == nil { - args = []string{} - } - server["args"] = args - env, err := namedValues(entry["env"]) - if err != nil { - return nil, err - } - server["env"] = env - } - result = append(result, server) - } - return result, nil -} - -func namedValues(raw any) ([]map[string]string, error) { - result := []map[string]string{} - if raw == nil { - return result, nil - } - values, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("mcode: MCP headers/env must be an object") - } - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - value, ok := values[key].(string) - if !ok { - return nil, fmt.Errorf("mcode: MCP headers/env values must be strings") - } - result = append(result, map[string]string{"name": key, "value": value}) - } - return result, nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/options_test.go b/apps/parsar-daemon/internal/agent/mcode/options_test.go deleted file mode 100644 index a19117546..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/options_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestOptionsRefreshManagedState(t *testing.T) { - req := testRequest(t) - req.AgentOptions["env"] = map[string]any{"MINIMAX_DATA_DIR": "/wrong", "FIXTURE": "yes"} - opts, err := prepareOptions(context.Background(), req) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(opts.Dir, os.Getenv("OAC_RUNTIME_HOME")+string(os.PathSeparator)) { - t.Fatalf("workdir escaped managed state: %s", opts.Dir) - } - if opts.Env[len(opts.Env)-1] != "MINIMAX_DATA_DIR="+opts.DataDir { - t.Fatal("state override did not win") - } - req.AgentOptions["system_prompt"] = "" - req.AgentOptions["mode"] = "default" - req.AgentSessionID = "native-1" - refreshed, err := prepareOptions(context.Background(), req) - if err != nil { - t.Fatal(err) - } - if refreshed.DataDir != opts.DataDir { - t.Fatal("resume moved native state") - } - content, err := os.ReadFile(filepath.Join(opts.DataDir, "AGENTS.md")) - if err != nil || len(content) != 0 { - t.Fatal("removed instructions retained on resume") - } - data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) - if err != nil { - t.Fatal(err) - } - var cfg map[string]any - if json.Unmarshal(data, &cfg) != nil { - t.Fatal("invalid config") - } - if cfg["permissionMode"] != "default" { - t.Fatal("requested native permission mode was not refreshed") - } - if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { - t.Fatal("external discovery enabled") - } - info, _ := os.Stat(filepath.Join(opts.DataDir, "config.yaml")) - if info.Mode().Perm() != 0600 { - t.Fatal("native credentials file is not private") - } -} - -func TestOptionsRejectDroppedContext(t *testing.T) { - tests := []struct { - name string - edit func(*proto.PromptRequestPayload) - }{ - {"relative workdir", func(r *proto.PromptRequestPayload) { r.WorkDir = "relative" }}, - {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, - {"attachment", func(r *proto.PromptRequestPayload) { - r.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} - }}, - {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, - {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, - {"invalid permission mode", func(r *proto.PromptRequestPayload) { r.AgentOptions["mode"] = "plan" }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := testRequest(t) - tt.edit(&req) - if _, err := prepareOptions(context.Background(), req); err == nil { - t.Fatal("expected validation failure") - } - }) - } -} - -func TestMCPTransportConversion(t *testing.T) { - servers, err := mcpServers(map[string]any{ - "local": map[string]any{"command": "fixture", "args": []any{"--stdio"}, "env": map[string]any{"TOKEN": "test"}}, - "remote": map[string]any{"type": "http", "url": "https://mcp.example.test", "headers": map[string]any{"Authorization": "Bearer fixture"}}, - }) - if err != nil { - t.Fatal(err) - } - if len(servers) != 2 || servers[0]["command"] != "fixture" || servers[1]["type"] != "http" { - t.Fatalf("servers=%v", servers) - } - if servers[0]["env"].([]map[string]string)[0]["name"] != "TOKEN" || servers[1]["headers"].([]map[string]string)[0]["value"] != "Bearer fixture" { - t.Fatal("MCP credentials lost") - } -} - -func TestQuestionContentPreservesTypesAndValidates(t *testing.T) { - pending := pendingQuestion{Properties: map[string]formProperty{"text": {Type: "string"}, "choice": {Type: "string", Options: []formOption{{Value: "eu", Title: "Europe"}}}}, Required: []string{"choice"}} - if _, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{}); err == nil { - t.Fatal("required answer accepted empty") - } - decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "text", Answers: []string{"custom"}}, {QuestionID: "choice", Answers: []string{"Europe"}}}} - content, err := questionContent(pending, decision) - if err != nil { - t.Fatal(err) - } - if content["choice"] != "eu" || content["text"] != "custom" { - t.Fatalf("answers=%v", content) - } - decision.QuestionAnswers[1].Answers = []string{"unoffered"} - if _, err := questionContent(pending, decision); err == nil { - t.Fatal("unoffered choice accepted") - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation.go b/apps/parsar-daemon/internal/agent/mcode/preparation.go deleted file mode 100644 index 8d9672548..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/preparation.go +++ /dev/null @@ -1,87 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Prepared retains its single admission API over the same native Executor. -// Runtime's Session lifecycle uses Executor directly. -type prepared struct { - mu sync.Mutex - executor *executor - session *Session - started, closed bool -} - -func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { - factory := NewExecutorFactory(&config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - value, err := factory(ctx, req) - if err != nil { - if value != nil { - return &prepared{executor: value.(*executor)}, err - } - return nil, err - } - e := value.(*executor) - return &prepared{executor: e, session: newTurnSession(ctx, req, e.opts, e.connection, nil)}, nil - } -} - -func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || p.started { - return nil, fmt.Errorf("mcode: preparation is no longer available") - } - turn, err := p.executor.StartTurn(ctx, runID, input, out) - if turn != nil { - p.started, p.session = true, turn.(*Session) - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - started := p.started - if !started { - p.closed = true - } - p.mu.Unlock() - if started { - return nil - } - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - current, started := p.session, p.started - p.mu.Unlock() - if started { - if err := current.Cancel(ctx); err != nil { - // The single-use Prepared owns disposal as well as cancellation. - if closeErr := p.executor.Close(ctx); closeErr != nil { - return closeErr - } - return nil - } - } - return p.executor.Close(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - s := p.session - p.mu.Unlock() - if s == nil { - return proto.DonePayload{} - } - return s.CancellationOutcome() -} diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go deleted file mode 100644 index f6f3d6f9b..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { - t.Helper() - r := executionRequest(t) - r.RunID, r.Input, r.ConversationID = "", nil, "" - r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} - r.WorkDir = t.TempDir() - record := filepath.Join(t.TempDir(), "calls") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "native") - quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "enabled", Scratch: t.TempDir()}, r, record -} - -func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") { - t.Fatalf("preparation consumed input: %q %v", raw, err) - } - if p.session.opts.Dir != r.WorkDir || p.session.opts.DataDir == r.WorkDir { - t.Fatal("native cwd must use the workspace without moving Session state") - } - out := make(chan proto.Envelope) - var wg sync.WaitGroup - winners := make(chan bool, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - _, err := p.Start(ctx, "run", proto.TextInput("input"), out) - winners <- err == nil - }() - } - wg.Wait() - close(winners) - n := 0 - for winner := range winners { - if winner { - n++ - } - } - if n != 1 { - t.Fatalf("owners=%d", n) - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - deltas, done := 0, 0 - for e := range out { - if e.Type == proto.TypeError { - t.Fatalf("execution: %s", e.Payload) - } - if e.Type == proto.TypeDelta { - deltas++ - } - if e.Type == proto.TypeDone { - done++ - select { - case <-p.session.exited: - t.Fatal("successful Turn disposed the reusable native owner") - default: - } - var d proto.DonePayload - _ = json.Unmarshal(e.Payload, &d) - if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("native identity lost") - } - } - } - if deltas != 100 || done != 1 { - t.Fatalf("deltas=%d done=%d", deltas, done) - } - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/prompt") != 1 { - t.Fatalf("inputs=%s", raw) - } -} - -func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { - c, r, _ := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } - if _, err = resource.Start(ctx, "run", proto.TextInput("input"), make(chan proto.Envelope)); err == nil { - t.Fatal("released preparation started") - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } -} - -func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { - for _, method := range []string{"close", "cancel"} { - t.Run(method, func(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - // The fixture only implements preparation. Enable execution cancellation's - // child branch after initialization to verify unused owners never enter it. - p.session.req.DisableSubagents = false - ended := make(chan error, 1) - go func() { - if method == "close" { - ended <- p.Close() - } else { - ended <- p.Cancel(ctx) - } - }() - select { - case err := <-ended: - if err != nil { - t.Fatal(err) - } - case <-ctx.Done(): - p.session.process.Cancel() - t.Fatal("unused owner did not close") - } - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { - t.Fatalf("unused preparation executed work: %q %v", raw, err) - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go deleted file mode 100644 index e65948f08..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ /dev/null @@ -1,409 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "slices" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type Session struct { - ctx context.Context - req proto.PromptRequestPayload - opts launchOptions - *connection - executor *executor - settlement agent.TurnSettlement - settlementErr error - settled chan struct{} - inputDone chan struct{} - outputCancel context.CancelFunc - operations sync.WaitGroup - closing bool - cancelled bool - inputUncertain bool - out chan<- proto.Envelope - frames chan rpcFrame - finished chan struct{} - mu sync.Mutex - sessionID string - nativeModel string - outputContext context.Context - outcome proto.DonePayload - permissions map[string]pendingPermission - questions map[string]pendingQuestion - steeringReady bool - steeringTurn string - sequence uint64 - active bool - content strings.Builder - tools map[string]toolUpdate - completedTools map[string]bool - previousNativeTurns map[string]bool - subagentSettlementError error - rootCompletedAtMS *int64 - subagentHistoryReady bool -} - -var _ agent.Session = (*Session)(nil) - -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultBinary()) -} - -func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, binary string) (*Session, error) { - if ctx == nil { - ctx = context.Background() - } - if out == nil { - return nil, fmt.Errorf("mcode: output channel is required") - } - opts, err := prepareOptions(ctx, req) - if err != nil { - return nil, err - } - s, err := launch(ctx, req, opts, binary, out) - if err != nil { - return nil, err - } - go s.run() - return s, nil -} - -func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string, out chan<- proto.Envelope) (*Session, error) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) - if err != nil { - return nil, err - } - c := &connection{process: process, exited: make(chan struct{}), responses: map[string]chan rpcFrame{}} - s := newTurnSession(ctx, req, opts, c, out) - c.current = s - go c.read() - return s, nil -} - -func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { - return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} -} - -func (s *Session) run() { - defer func() { - if s.out != nil { - close(s.out) - } - }() - defer close(s.finished) - err := s.prepareNative() - if err == nil { - if s.req.StrictResume && !s.req.DisableSubagents { - var snapshot nativeSubagentSnapshot - snapshot, err = s.readSubagents(s.ctx) - s.subagentHistoryReady = err == nil - s.previousNativeTurns = map[string]bool{} - for _, session := range snapshot.Sessions { - if session.ID == s.sessionID { - for _, turn := range session.Turns { - s.previousNativeTurns[turn.ID] = true - } - } - } - } - } - if err == nil { - err = s.executePrompt() - } - if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { - observationErr := s.settleSubagents() - s.mu.Lock() - s.subagentSettlementError = observationErr - s.mu.Unlock() - if observationErr != nil { - err = observationErr - } - } - if err != nil { - s.process.Cancel() - <-s.exited - } - s.finishEnvironmentMCP() - if s.out == nil { - return - } - s.mu.Lock() - s.steeringReady = false - s.mu.Unlock() - if err != nil { - s.process.Cancel() - s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) - } - s.mu.Lock() - sessionID := s.sessionID - s.permissions = map[string]pendingPermission{} - s.questions = map[string]pendingQuestion{} - s.mu.Unlock() - metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} - if sessionID != "" { - metadata[proto.DoneMetaAgentSessionID] = sessionID - } - // ACP context usage is not per-turn token usage; do not record it as spend. - s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS}) -} - -func (s *Session) prepareNative() error { - var initialized struct { - ProtocolVersion int `json:"protocolVersion"` - Meta struct { - Subagents struct { - Version, MaxConcurrent int - WorkspaceTools string - } `json:"oac/subagents"` - } `json:"_meta"` - } - if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "oac", "version": "1"}, "clientCapabilities": map[string]any{"elicitation": map[string]any{"form": map[string]any{}}}}, &initialized, false); err != nil { - return err - } - if initialized.ProtocolVersion != 1 { - return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) - } - if s.req.StrictResume && !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { - return fmt.Errorf("mcode: native Subagent admission is unavailable") - } - params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} - method := "session/new" - if s.req.AgentSessionID != "" { - method = "session/load" - params["sessionId"] = s.req.AgentSessionID - } - var session sessionResult - if err := s.call(method, params, &session, false); err != nil { - return err - } - if s.req.AgentSessionID != "" { - session.SessionID = s.req.AgentSessionID - } - if session.SessionID == "" { - return fmt.Errorf("mcode: ACP returned an empty session id") - } - s.mu.Lock() - s.sessionID = session.SessionID - s.mu.Unlock() - model, err := advertisedModel(session.ConfigOptions, s.opts.Model) - if err != nil && s.req.AgentSessionID != "" && !slices.ContainsFunc(session.ConfigOptions, func(option configOption) bool { return option.ID == "model" }) { - // Native load omits the selector when its persisted model was removed; selection still validates against the current catalog. - model = "m:custom_provider%3Aoac:" + strings.ReplaceAll(url.QueryEscape(s.opts.Model), "+", "%20") + ":v:" - err = nil - } - if err != nil { - return err - } - s.nativeModel = model - if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { - return err - } - return nil -} - -func (s *Session) executePrompt() error { - prompt, err := s.req.Input.TextOnly() - if err != nil { - return err - } - s.active = true - var result struct { - StopReason string `json:"stopReason"` - } - err = s.call("session/prompt", map[string]any{"sessionId": s.sessionID, "prompt": promptContent(prompt, s.req.StrictResume)}, &result, true) - s.active = false - s.mu.Lock() - s.steeringReady = false - s.mu.Unlock() - if err != nil { - return err - } - s.mu.Lock() - cancelled := s.cancelled - s.mu.Unlock() - if result.StopReason == "cancelled" && cancelled { - return nil - } - if result.StopReason != "end_turn" { - return fmt.Errorf("mcode: prompt stopped (%s)", result.StopReason) - } - return nil -} - -// Select the advertised custom model, rather than using mcode's native default. -func advertisedModel(options []configOption, model string) (string, error) { - for _, option := range options { - if option.ID != "model" { - continue - } - for _, candidate := range option.Options { - parts := strings.Split(candidate.Value, ":") - if len(parts) < 4 || parts[0] != "m" { - continue - } - provider, err := decodeComponent(parts[1]) - if err != nil { - continue - } - id, err := decodeComponent(parts[2]) - if err != nil { - continue - } - if provider == "custom_provider:oac" && id == model && (parts[3] == "u" || (len(parts) == 5 && parts[3] == "v" && parts[4] == "")) { - return candidate.Value, nil - } - } - } - return "", fmt.Errorf("mcode: configured model is not advertised by the CLI") -} - -func (s *Session) call(method string, params any, result any, prompt bool) error { - id, _ := s.reserveResponse() - s.removeResponse(id) - raw, err := json.Marshal(params) - if err != nil { - return err - } - if prompt && s.executor != nil { - // Serialize the admission check with the wire, but release the owner - // mutex before a pipe write so cancellation and Close can stop it. - s.connection.writeMu.Lock() - s.executor.mu.Lock() - s.mu.Lock() - cancelled := s.cancelled - s.mu.Unlock() - if cancelled || s.executor.closed { - s.executor.mu.Unlock() - s.connection.writeMu.Unlock() - return errTurnCancelled - } - s.executor.mu.Unlock() - err = json.NewEncoder(s.process.Stdin).Encode(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) - s.connection.writeMu.Unlock() - } else { - err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) - } - if err != nil { - return err - } - ctx := s.process.Context() - if !prompt { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 60*time.Second) - defer cancel() - } - for { - select { - case <-ctx.Done(): - return fmt.Errorf("mcode: %s: %w", method, ctx.Err()) - case <-s.exited: - return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) - case frame, ok := <-s.frames: - if !ok { - <-s.exited - return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) - } - if frame.Method != "" { - if err := s.handle(frame); err != nil { - return err - } - continue - } - if string(frame.ID) != id { - continue - } - if frame.Error != nil { - return fmt.Errorf("mcode: %s: %s", method, frame.Error.Message) - } - if result != nil { - if err := json.Unmarshal(frame.Result, result); err != nil { - return fmt.Errorf("mcode: invalid %s result", method) - } - } - return nil - } - } -} - -func (s *Session) emit(kind string, payload any) { - ctx := s.ctx - if s.outputContext != nil { - ctx = s.outputContext - } - env, err := proto.NewEnvelope(kind, s.req.RunID, payload) - if err != nil { - return - } - select { - case s.out <- env: - return - default: - } - select { - case s.out <- env: - case <-ctx.Done(): - } -} - -func (s *Session) Cancel(ctx context.Context) error { - if s.executor != nil { - return s.cancelTurn(ctx) - } - if s.req.StrictResume && !s.req.DisableSubagents { - if err := s.cancelSubagents(ctx); err != nil { - s.process.Cancel() - return err - } - } - s.process.Cancel() - if !s.req.StrictResume { - return nil - } - select { - case <-s.exited: - case <-ctx.Done(): - return ctx.Err() - } - select { - case <-s.finished: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func (s *Session) SubmitPermission(_ context.Context, id string, decision proto.PermissionDecisionPayload) error { - s.mu.Lock() - defer s.mu.Unlock() - pending, ok := s.permissions[id] - if !ok { - return agent.ErrUnknownPermission - } - choice := pending.Deny - if decision.Approved { - choice = pending.Allow - } - if choice == "" { - return errors.New("mcode: ACP permission option is unavailable") - } - if len(decision.UpdatedInput) > 0 { - return errors.New("mcode: edited permission input is not supported") - } - raw, _ := json.Marshal(map[string]any{"outcome": map[string]string{"outcome": "selected", "optionId": choice}}) - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: pending.RPCID, Result: raw}); err != nil { - return err - } - delete(s.permissions, id) - return nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go deleted file mode 100644 index 22fddfdeb..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package mcode - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func testRequest(t *testing.T) proto.PromptRequestPayload { - t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - return proto.PromptRequestPayload{RunID: "run-1", ConversationID: "conversation-1", AgentStateKey: "conversation-1/agent-1/mcode", Input: proto.TextInput("Hello"), AgentOptions: map[string]any{ - "model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/v1", "api_key": "fixture-key", "context_window": 64000, "max_output_tokens": 4096}, "system_prompt": "Current instructions", - }} -} - -func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { - t.Helper() - req := testRequest(t) - if scenario == "strict-cancel" { - req = executionRequest(t) - } - if resume { - req.AgentSessionID = "native-1" - } - t.Setenv("OAC_TEST_MCODE_HELPER", scenario) - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "mcode") - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=" + scenario + "\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - t.Cleanup(cancel) - out := make(chan proto.Envelope, 32) - session, err := newSession(ctx, req, out, binary) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = session.Cancel(context.Background()) - select { - case <-session.exited: - case <-time.After(3 * time.Second): - t.Error("CLI was not reaped") - } - }) - return session, out -} - -func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { - for _, resume := range []bool{false, true} { - t.Run(map[bool]string{false: "new", true: "resume"}[resume], func(t *testing.T) { - _, out := helperSession(t, "happy", resume) - var content string - tools := map[string]int{} - doneCount := 0 - for event := range out { - switch event.Type { - case proto.TypeError: - t.Fatalf("error: %s", event.Payload) - case proto.TypeDelta: - var p proto.DeltaPayload - _ = json.Unmarshal(event.Payload, &p) - content += p.Delta - case proto.TypeToolCall: - var p proto.ToolCallPayload - _ = json.Unmarshal(event.Payload, &p) - tools[p.Stage]++ - case proto.TypeDone: - doneCount++ - var p proto.DonePayload - _ = json.Unmarshal(event.Payload, &p) - 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 { - t.Fatalf("context occupancy reported as usage: %#v", p.Usage) - } - } - } - if content != "Hello world" || doneCount != 1 || tools["before"] != 1 || tools["after"] != 1 { - t.Fatalf("content=%q done=%d tools=%v", content, doneCount, tools) - } - }) - } -} - -func TestSessionInteractionRoundTrip(t *testing.T) { - for _, approved := range []bool{true, false} { - t.Run(map[bool]string{true: "allow", false: "deny"}[approved], func(t *testing.T) { - session, out := helperSession(t, "interaction", false) - permissions, questions := 0, 0 - for event := range out { - switch event.Type { - case proto.TypeError: - t.Fatalf("error: %s", event.Payload) - case proto.TypePermissionRequest: - permissions++ - var p proto.PermissionRequestPayload - _ = json.Unmarshal(event.Payload, &p) - if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: approved}); err != nil { - t.Fatal(err) - } - if err := session.SubmitPermission(context.Background(), p.RequestID, proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { - t.Fatalf("duplicate approval: %v", err) - } - case proto.TypePromptForUserChoice: - questions++ - var p proto.PromptForUserChoicePayload - _ = json.Unmarshal(event.Payload, &p) - decision := proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe"}}}} - if err := session.SubmitPromptForUserChoice(context.Background(), p.AskID, decision); err != nil { - t.Fatal(err) - } - } - } - if permissions != 1 || questions != 1 { - t.Fatalf("permissions=%d questions=%d", permissions, questions) - } - }) - } -} - -func TestResumeSelectsModelWhenNativeSelectorIsMissing(t *testing.T) { - _, out := helperSession(t, "resume-model", true) - completed := false - for event := range out { - if event.Type == proto.TypeError { - t.Fatalf("resume failed: %s", event.Payload) - } - if event.Type == proto.TypeDone { - completed = true - } - } - if !completed { - t.Fatal("resume did not complete") - } -} - -func TestSessionFailuresAreReported(t *testing.T) { - for _, scenario := range []string{"malformed", "exit", "rpc-error", "unknown-model"} { - t.Run(scenario, func(t *testing.T) { - _, out := helperSession(t, scenario, false) - reported := false - for event := range out { - if event.Type == proto.TypeError { - reported = true - } - } - if !reported { - t.Fatal("failure was not emitted") - } - }) - } -} - -func TestCancelStopsWaitingCLI(t *testing.T) { - session, out := helperSession(t, "hang", false) - if err := session.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - select { - case <-session.exited: - case <-time.After(3 * time.Second): - t.Fatal("cancel hung") - } - for range out { - } -} - -func TestMCodeProcess(t *testing.T) { - scenario := os.Getenv("OAC_TEST_MCODE_HELPER") - if scenario == "" { - return - } - encoder := json.NewEncoder(os.Stdout) - send := func(value any) { - if err := encoder.Encode(value); err != nil { - os.Exit(2) - } - } - update := func(kind string, fields map[string]any) { - fields["sessionUpdate"] = kind - send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "native-1", "update": fields}}) - } - scanner := bufio.NewScanner(os.Stdin) - var promptID json.RawMessage - prompts, configurations := 0, 0 - for scanner.Scan() { - var frame rpcFrame - if json.Unmarshal(scanner.Bytes(), &frame) != nil { - os.Exit(3) - } - if scenario == "malformed" { - os.Stdout.WriteString("invalid JSON\n") - os.Exit(0) - } - if scenario == "exit" { - os.Exit(1) - } - if scenario == "hang" { - continue - } - if record := os.Getenv("OAC_TEST_MCODE_RECORD"); record != "" { - f, err := os.OpenFile(record, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) - if err != nil { - os.Exit(10) - } - _, _ = f.WriteString(frame.Method + "\n") - _ = f.Close() - if frame.Method == "session/new" || frame.Method == "session/load" { - cwd, err := os.Getwd() - if err != nil || os.WriteFile(record+".cwd", []byte(cwd), 0600) != nil { - os.Exit(11) - } - if os.WriteFile(record+".session", frame.Params, 0600) != nil { - os.Exit(11) - } - } - } - result := any(map[string]any{}) - switch frame.Method { - case "initialize": - result = map[string]int{"protocolVersion": 1} - case "session/new", "session/load": - if scenario == "prepared-mcp-cancel" { - if writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("proof.server", "proof_server", "read.status", "read_status")) != nil { - os.Exit(12) - } - } - if frame.Method == "session/load" { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) - } - model := "m:custom_provider%3Aoac:fixture:v:" - if scenario == "unknown-model" { - model = "m:minimax:native:u" - } - result = map[string]any{"sessionId": "native-1", "configOptions": []map[string]any{{"id": "model", "options": []map[string]string{{"value": model}}}}} - if scenario == "resume-model" { - result = map[string]any{"configOptions": []map[string]any{{"id": "permissionMode"}}} - } - case "session/set_config_option": - configurations++ - if scenario == "executor-backpressure" && configurations > 1 { - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: json.RawMessage("{}")}) - time.Sleep(time.Minute) - os.Exit(0) - } - if scenario == "executor-preexit" && configurations > 1 { - os.Exit(0) - } - var params map[string]string - _ = json.Unmarshal(frame.Params, ¶ms) - if params["configId"] == "model" && params["value"] != "m:custom_provider%3Aoac:fixture:v:" { - os.Exit(4) - } - case "session/prompt": - var input struct { - Prompt []map[string]string `json:"prompt"` - } - _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || (strings.HasPrefix(scenario, "prepared") || strings.HasPrefix(scenario, "executor")); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { - os.Exit(9) - } - if strings.HasPrefix(scenario, "executor") { - prompts++ - promptID = frame.ID - if scenario == "executor-exit" { - os.Exit(0) - } - if input.Prompt[0]["text"] == "wait" { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) - continue - } - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) - update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "Read", "status": "in_progress", "rawInput": map[string]any{}}) - update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) - raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) - // These frames precede the next control barrier on the wire and - // must never become the following Turn's output. - for range 100 { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD"}}) - } - continue - } - if scenario == "prepared-mcp-cancel" { - promptID = frame.ID - update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) - continue - } - if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { - promptID = frame.ID - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) - continue - } - if scenario == "many-frames" || scenario == "prepared" { - for range 100 { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "x"}}) - } - result = map[string]string{"stopReason": "end_turn"} - break - } - if scenario == "rpc-error" { - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32603, Message: "Fixture provider unavailable"}}) - continue - } - if scenario == "interaction" { - promptID = frame.ID - send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "always", "kind": "allow_always"}, {"optionId": "deny", "kind": "reject_once"}}}}) - continue - } - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) - update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "Read", "status": "in_progress", "rawInput": map[string]any{"path": "fixture.txt"}}) - for range 2 { - update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) - } - update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) - result = map[string]string{"stopReason": "end_turn"} - case "mcode/session/delegation/stop": - result = map[string]any{"receipt": map[string]any{"failedSessionIds": []string{}}} - case "session/cancel": - raw, _ := json.Marshal(map[string]string{"stopReason": "cancelled"}) - send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) - continue - case "mcode/session/steer": - if scenario == "executor-steer-unknown" { - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32000, Message: "Unknown input outcome"}}) - continue - } - if scenario == "steer-lost" { - os.Exit(0) - } - if scenario == "steer-rejected" { - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "inactive"}}) - continue - } - raw, _ := json.Marshal(map[string]string{"turnId": "native-turn", "mode": "steered"}) - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "-steered"}}) - raw, _ = json.Marshal(map[string]string{"stopReason": "end_turn"}) - send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) - continue - case "": - if string(frame.ID) == `"permission-1"` { - var reply struct { - Outcome struct { - Option string `json:"optionId"` - } `json:"outcome"` - } - _ = json.Unmarshal(frame.Result, &reply) - if reply.Outcome.Option != "once" && reply.Outcome.Option != "deny" { - os.Exit(5) - } - send(map[string]any{"jsonrpc": "2.0", "id": "question-1", "method": "elicitation/create", "params": map[string]any{"sessionId": "native-1", "mode": "form", "requestedSchema": map[string]any{"type": "object", "required": []string{"region"}, "properties": map[string]any{"region": map[string]any{"type": "string", "title": "Region?", "oneOf": []map[string]string{{"const": "eu", "title": "Europe"}, {"const": "us", "title": "America"}}}}}}}) - } else { - var reply struct { - Action string `json:"action"` - Content map[string]string `json:"content"` - } - _ = json.Unmarshal(frame.Result, &reply) - if reply.Action != "accept" || reply.Content["region"] != "eu" { - os.Exit(6) - } - raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) - send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) - } - continue - } - raw, _ := json.Marshal(result) - send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) - } - os.Exit(0) -} diff --git a/apps/parsar-daemon/internal/agent/mcode/steering.go b/apps/parsar-daemon/internal/agent/mcode/steering.go deleted file mode 100644 index cb16925bc..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/steering.go +++ /dev/null @@ -1,130 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -var _ agent.DurableSteerer = (*Session)(nil) - -func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, input, nil) -} - -// Native acceptance belongs to the active ACP Turn; it does not promise model consumption. -func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { - if s.executor != nil { - s.mu.Lock() - if s.closing || s.cancelled { - s.mu.Unlock() - return agent.ErrSteeringInactive - } - s.operations.Add(1) - s.mu.Unlock() - defer s.operations.Done() - } - text, err := input.Input.TextOnly() - if strings.TrimSpace(input.InputID) == "" || err != nil { - return agent.ErrSteeringRejected - } - s.mu.Lock() - ready, native := s.steeringReady, s.sessionID - s.mu.Unlock() - if s.process.Context().Err() != nil { - return agent.ErrSteeringInactive - } - if !ready || native == "" { - return agent.ErrSteeringNotReady - } - id, response := s.reserveResponse() - defer s.removeResponse(id) - raw, err := json.Marshal(map[string]string{"sessionId": native, "text": text, "clientRequestId": input.InputID}) - if err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) - if err != nil { - s.markInputUncertain() - return fmt.Errorf("mcode: input transport failed") - } - if written != nil { - written() - } - var frame rpcFrame - var stopped error - select { - case frame = <-response: - case <-s.inputSettlementDone(): - stopped = fmt.Errorf("mcode: run ended with unknown input outcome") - case <-s.exited: - stopped = fmt.Errorf("mcode: input transport closed") - case <-ctx.Done(): - stopped = ctx.Err() - } - if stopped != nil { - // A native receipt already read before terminal closure remains authoritative. - select { - case frame = <-response: - default: - s.markInputUncertain() - return stopped - } - } - if frame.Error != nil { - if frame.Error.Code == -32602 || frame.Error.Code == -32601 { - return agent.ErrSteeringRejected - } - s.markInputUncertain() - return fmt.Errorf("mcode: native steering failed with unknown input outcome") - } - var result struct { - TurnID string `json:"turnId"` - Mode string `json:"mode"` - } - if json.Unmarshal(frame.Result, &result) != nil || result.TurnID == "" || (result.Mode != "steered" && result.Mode != "duplicate") { - s.markInputUncertain() - return fmt.Errorf("mcode: invalid steering receipt") - } - s.mu.Lock() - defer s.mu.Unlock() - if s.steeringTurn != "" && s.steeringTurn != result.TurnID { - s.inputUncertain = true - return fmt.Errorf("mcode: steering turn changed") - } - s.steeringTurn = result.TurnID - return nil -} - -func (s *Session) inputSettlementDone() <-chan struct{} { - if s.inputDone != nil { - return s.inputDone - } - return s.finished -} - -func (s *Session) CancellationOutcome() proto.DonePayload { - s.mu.Lock() - defer s.mu.Unlock() - if s.outcome.Metadata != nil { - return s.outcome - } - metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} - if s.sessionID != "" { - metadata[proto.DoneMetaAgentSessionID] = s.sessionID - } - return proto.DonePayload{Metadata: metadata} -} - -func (s *Session) markInputUncertain() { - s.mu.Lock() - s.inputUncertain = true - s.mu.Unlock() -} diff --git a/apps/parsar-daemon/internal/agent/mcode/steering_test.go b/apps/parsar-daemon/internal/agent/mcode/steering_test.go deleted file mode 100644 index df5dfc1ab..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/steering_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestNativeSteeringReceipt(t *testing.T) { - for _, scenario := range []string{"steering", "steer-rejected", "steer-lost"} { - t.Run(scenario, func(t *testing.T) { - s, out := helperSession(t, scenario, false) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - select { - case event := <-out: - if event.Type != proto.TypeDelta { - t.Fatalf("first event %s", event.Type) - } - case <-ctx.Done(): - t.Fatal("not ready") - } - written := false - reply := make(chan error, 1) - go func() { - reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("next")}, func() { written = true }) - }() - // Drain native output concurrently, as the router does. - drained := make(chan struct{}) - go func() { - for range out { - } - close(drained) - }() - err := <-reply - if !written { - t.Fatal("complete write was not reported") - } - switch scenario { - case "steering": - if err != nil { - t.Fatal(err) - } - case "steer-rejected": - if !errors.Is(err, agent.ErrSteeringRejected) { - t.Fatalf("got %v", err) - } - case "steer-lost": - if err == nil || errors.Is(err, agent.ErrSteeringRejected) { - t.Fatalf("unknown outcome became rejection/success: %v", err) - } - } - s.Cancel(ctx) - select { - case <-drained: - case <-ctx.Done(): - t.Fatal("output not closed") - } - }) - } -} - -func TestTerminalFollowsAllNativeFrames(t *testing.T) { - _, out := helperSession(t, "many-frames", false) - count := 0 - for e := range out { - switch e.Type { - case proto.TypeDelta: - count++ - case proto.TypeDone: - var done proto.DonePayload - _ = json.Unmarshal(e.Payload, &done) - if count != 100 || len(done.Content) != 100 { - t.Fatalf("terminal overtook frames: %d/%d", count, len(done.Content)) - } - case proto.TypeError: - t.Fatalf("unexpected error %s", e.Payload) - } - } -} - -func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { - s, out := helperSession(t, "strict-cancel", false) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - select { - case <-out: - case <-ctx.Done(): - t.Fatal("not ready") - } - drained := make(chan struct{}) - go func() { - for range out { - } - close(drained) - }() - if err := s.Cancel(ctx); err != nil { - t.Fatal(err) - } - select { - case <-s.exited: - default: - t.Fatal("cancel returned before process exit") - } - select { - case <-s.finished: - default: - t.Fatal("cancel returned before output settlement") - } - if err := s.Cancel(ctx); err != nil { - t.Fatal("duplicate cancellation", err) - } - select { - case <-drained: - case <-ctx.Done(): - t.Fatal("output not closed") - } - if s.CancellationOutcome().Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("lost native binding") - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go b/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go deleted file mode 100644 index 0db38b2e0..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go +++ /dev/null @@ -1,52 +0,0 @@ -package mcode - -import ( - "encoding/json" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func nativeCoordination(session nativeSubagentSession, tool nativeSubagentTool) (*proto.SubagentCoordinationPayload, error) { - if tool.Status != 2 || (tool.Name != "task" && tool.Name != "task_append") { - return nil, nil - } - var args struct { - Prompt, Content string - TaskID string `json:"task_id"` - } - if json.Unmarshal([]byte(tool.Args), &args) != nil { - return nil, fmt.Errorf("mcode: invalid delegation arguments") - } - value := &proto.SubagentCoordinationPayload{ID: tool.ID, ActorID: session.ID, Status: "completed"} - if tool.Name == "task" { - value.Kind, value.Text = "create_subagent_call", &args.Prompt - for _, task := range session.Tasks { - if task.ToolCallID == tool.ID && task.Metadata.ExecutionMode != "append" { - value.Recipients = []string{task.Metadata.ChildSessionID} - break - } - } - } else { - var result struct { - Details struct { - Accepted bool `json:"accepted"` - TaskID string `json:"task_id"` - } `json:"details"` - } - if json.Unmarshal([]byte(tool.Result), &result) != nil || !result.Details.Accepted { - return nil, fmt.Errorf("mcode: delegation receipt is missing") - } - value.Kind, value.Text = "send_subagent_input_call", &args.Content - for _, task := range session.Tasks { - if task.TaskID == result.Details.TaskID { - value.Recipients = []string{task.Metadata.ChildSessionID} - break - } - } - } - if len(value.Recipients) != 1 || value.Recipients[0] == "" { - return nil, fmt.Errorf("mcode: successful delegation lacks child identity") - } - return value, nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents.go b/apps/parsar-daemon/internal/agent/mcode/subagents.go deleted file mode 100644 index 645525f54..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/subagents.go +++ /dev/null @@ -1,217 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type nativeSubagentSnapshot struct { - Version int `json:"version"` - Root string `json:"rootSessionId"` - Complete bool `json:"complete"` - Sessions []nativeSubagentSession `json:"sessions"` -} -type nativeSubagentSession struct { - ID, Parent, Name, Kind, Purpose, Status string - CreatedAt int64 - Turns []nativeSubagentTurn - Messages []nativeSubagentMessage - Tasks []nativeSubagentTask -} -type nativeSubagentTurn struct { - ID, Status string - CreatedAt int64 - CompletedAt *int64 -} -type nativeSubagentMessage struct { - ID, TurnID, Role string - CreatedAt int64 - Data struct { - Text string `json:"msg_content"` - Thinking string `json:"thinking_content"` - Tools []nativeSubagentTool `json:"tool_calls"` - } -} -type nativeSubagentTool struct { - ID string `json:"tool_call_id"` - Name string `json:"tool_name"` - Status int `json:"tool_call_status"` - Args string `json:"tool_call_args"` - Result string `json:"tool_call_result_data"` -} -type nativeSubagentTask struct { - TaskID, ToolCallID, OwnerSessionID, Status string - Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } -} - -func subagentReader() (string, string, error) { - node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") - reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") - for _, path := range []string{node, bridge, reader} { - resolved, err := filepath.EvalSymlinks(path) - if err != nil || !filepath.IsAbs(path) || resolved != path { - return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") - } - } - return node, reader, nil -} - -func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { - var snapshot nativeSubagentSnapshot - node, reader, err := subagentReader() - if err != nil { - return snapshot, err - } - ctx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - command := exec.CommandContext(ctx, node, "--disable-warning=ExperimentalWarning", reader, s.opts.DataDir, s.sessionID) - command.Env = executionEnvironment() - stdout, err := command.StdoutPipe() - if err != nil { - return snapshot, fmt.Errorf("mcode: child history reader is unavailable") - } - if err := command.Start(); err != nil { - return snapshot, fmt.Errorf("mcode: child history reader is unavailable") - } - raw, readErr := io.ReadAll(io.LimitReader(stdout, 64*1024*1024+1)) - if readErr != nil || len(raw) > 64*1024*1024 { - _ = command.Process.Kill() - } - err = command.Wait() - if err != nil || readErr != nil || len(raw) > 64*1024*1024 { - return snapshot, fmt.Errorf("mcode: complete child history is unavailable") - } - if json.Unmarshal(raw, &snapshot) != nil || snapshot.Version != 1 || !snapshot.Complete || snapshot.Root != s.sessionID { - return snapshot, fmt.Errorf("mcode: invalid child history snapshot") - } - return snapshot, nil -} - -func (s *Session) settleSubagents() error { - if s.req.DisableSubagents { - return nil - } - ctx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) - defer cancel() - for { - snapshot, err := s.readSubagents(ctx) - if err != nil { - return err - } - settled := true - for _, session := range snapshot.Sessions { - if session.ID == snapshot.Root && s.rootCompletedAtMS == nil { - for _, turn := range session.Turns { - if !s.previousNativeTurns[turn.ID] && turn.Status == "completed" && turn.CompletedAt != nil { - s.rootCompletedAtMS = turn.CompletedAt - break - } - } - } - for _, task := range session.Tasks { - if task.Status == "queued" || task.Status == "running" || task.Status == "stopping" { - settled = false - } - } - for _, turn := range session.Turns { - if turn.Status == "accepted" { - settled = false - } - } - } - if settled { - return s.projectSubagents(snapshot) - } - // Root output is frozen. Drain native notifications while its existing - // background task owners finish; none becomes a root output delta. - select { - case <-ctx.Done(): - return fmt.Errorf("mcode: child settlement did not complete") - case _, ok := <-s.frames: - if !ok { - return fmt.Errorf("mcode: native owner ended before child settlement") - } - case <-time.After(200 * time.Millisecond): - } - } -} - -func (s *Session) projectSubagents(snapshot nativeSubagentSnapshot) error { - byID := map[string]nativeSubagentSession{} - tasks := map[string]nativeSubagentTask{} - for _, session := range snapshot.Sessions { - if session.ID == "" || byID[session.ID].ID != "" { - return fmt.Errorf("mcode: duplicate native child identity") - } - byID[session.ID] = session - for _, task := range session.Tasks { - if task.Metadata.ExecutionMode != "append" && task.Metadata.ChildSessionID != "" { - tasks[task.Metadata.ChildSessionID] = task - } - } - } - emitted := map[string]bool{snapshot.Root: true} - for len(emitted) < len(byID) { - progress := false - for _, session := range snapshot.Sessions { - if emitted[session.ID] || !emitted[session.Parent] { - continue - } - task, ok := tasks[session.ID] - if !ok || session.Kind != "task" || session.CreatedAt <= 0 || task.OwnerSessionID != session.Parent || task.Metadata.ParentTurnID == "" || task.ToolCallID == "" { - return fmt.Errorf("mcode: native child provenance is incomplete") - } - name := session.Name - s.emit(proto.TypeSubagentIdentity, proto.SubagentIdentityPayload{NativeID: session.ID, ParentNativeID: session.Parent, NativeCreatedAt: session.CreatedAt / 1000, ParentTurnID: task.Metadata.ParentTurnID, SourceItemID: task.ToolCallID, Name: &name}) - emitted[session.ID], progress = true, true - } - if !progress { - return fmt.Errorf("mcode: native child graph is incomplete") - } - } - for _, session := range snapshot.Sessions { - if session.ID == snapshot.Root { - for _, message := range session.Messages { - if s.previousNativeTurns[message.TurnID] { - continue - } - for _, tool := range message.Data.Tools { - value, err := nativeCoordination(session, tool) - if err != nil { - return err - } - if value != nil { - value.ActorID = "" - s.emit(proto.TypeSubagentCoordination, value) - } - } - } - continue - } - for _, turn := range session.Turns { - if turn.ID == "" || turn.CreatedAt <= 0 { - return fmt.Errorf("mcode: invalid child Turn") - } - status := map[string]string{"accepted": "in_progress", "completed": "completed", "failed": "failed", "aborted": "cancelled"}[turn.Status] - if status == "" || (status != "in_progress" && turn.CompletedAt == nil) { - return fmt.Errorf("mcode: incomplete child Turn boundary") - } - value := proto.SubagentTurnPayload{NativeID: session.ID, TurnID: turn.ID, Status: "in_progress", CreatedAtMS: turn.CreatedAt} - s.emit(proto.TypeSubagentTurn, value) - if err := s.projectSubagentMessages(session, turn); err != nil { - return err - } - value.Status, value.CompletedAtMS = status, turn.CompletedAt - s.emit(proto.TypeSubagentTurn, value) - } - } - return nil -} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go deleted file mode 100644 index 9268c5837..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func childSnapshot(t *testing.T) nativeSubagentSnapshot { - t.Helper() - var snapshot nativeSubagentSnapshot - const raw = `{"version":1,"complete":true,"rootSessionId":"root","sessions":[ - {"id":"root","turns":[{"id":"parent-turn","status":"completed","createdAt":1000,"completedAt":2000}],"tasks":[{"taskId":"task","ownerSessionId":"root","toolCallId":"spawn","status":"succeeded","metadata":{"childSessionId":"child","parentTurnId":"parent-turn","executionMode":"foreground"}}]}, - {"id":"child","parent":"root","kind":"task","name":"worker","createdAt":1100,"turns":[{"id":"turn","status":"completed","createdAt":1200,"completedAt":1800}],"messages":[{"id":"input","turnId":"turn","role":"user","data":{"msg_content":"child input"}},{"id":"answer","turnId":"turn","role":"assistant","data":{"msg_content":"child output","thinking_content":"native reasoning"}}]}]}` - if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { - t.Fatal(err) - } - return snapshot -} - -func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { - project := func(snapshot nativeSubagentSnapshot) []proto.Envelope { - out := make(chan proto.Envelope, 32) - s := &Session{ctx: context.Background(), out: out, previousNativeTurns: map[string]bool{}} - if err := s.projectSubagents(snapshot); err != nil { - t.Fatal(err) - } - close(out) - var events []proto.Envelope - for event := range out { - events = append(events, event) - } - return events - } - first := project(childSnapshot(t)) - second := project(childSnapshot(t)) - if len(first) != 6 { - t.Fatalf("got %d events", len(first)) - } - for i, event := range first { - if event.Type != second[i].Type || !reflect.DeepEqual(event.Payload, second[i].Payload) { - t.Fatal("cold snapshot identities changed") - } - } - if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[5].Type != proto.TypeSubagentTurn { - t.Fatal("identity/turn/item ordering changed") - } - var input proto.SubagentItemPayload - if err := json.Unmarshal(first[2].Payload, &input); err != nil { - t.Fatal(err) - } - if input.NativeID != "child" || input.TurnID != "turn" || input.ItemID != "input" || input.Kind != "message" { - t.Fatalf("invalid child ownership: %+v", input) - } -} - -func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { - snapshot := childSnapshot(t) - snapshot.Sessions[0].Tasks = nil - s := &Session{ctx: context.Background(), out: make(chan proto.Envelope, 32)} - if err := s.projectSubagents(snapshot); err == nil { - t.Fatal("accepted child without original spawning provenance") - } -} - -// The ACP cancelled response may precede the root native Turn becoming terminal. -func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { - dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") - for name, data := range map[string]string{ - node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", - bridge: "", - filepath.Join(dir, "subagent-snapshot.mjs"): "", - } { - if err := os.WriteFile(name, []byte(data), 0700); err != nil { - t.Fatal(err) - } - } - t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) - for _, status := range []string{"accepted", "aborted"} { - t.Run(status, func(t *testing.T) { - raw := []byte(`{"version":1,"complete":true,"rootSessionId":"root","sessions":[{"id":"root","turns":[{"id":"current","status":"` + status + `"}]}]}`) - if err := os.WriteFile(filepath.Join(dir, "snapshot.json"), raw, 0600); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - s := &Session{ctx: ctx, sessionID: "root", opts: launchOptions{DataDir: dir}, frames: make(chan rpcFrame)} - err := s.settleSubagents() - if (err != nil) != (status == "accepted") { - t.Fatalf("root %s settlement error = %v", status, err) - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go b/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go deleted file mode 100644 index ac8b3b8c9..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package mcode - -import ( - "bytes" - "encoding/json" - "io/fs" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/google/uuid" -) - -func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T) { - config, req, _ := workspaceFixture(t) - source := filepath.Join(t.TempDir(), "operator.json") - write := func(path, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - write(source, `{"MCP_TEST_TOKEN":"fixture-bearer-canary","TOOL_SECRET":"fixture-tool-canary"}`) - initialization := t.TempDir() - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", initialization) - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", source) - plugin := t.TempDir() - 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()) - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", "enabled") - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", "") - req.WorkDir, req.AgentStateKey = "", "agents-api-"+session - req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory - req.LocalEnvironment.Capabilities = true - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{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() - if err != nil { - t.Fatal(err) - } - configured, err := binding.Configure(req) - if err != nil { - t.Fatal(err) - } - prepared, err := binding.Prepare(t.Context(), configured) - if err != nil { - t.Fatal(err) - } - opts, err := prepareWorkspaceOptions(t.Context(), config, prepared) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) - if err != nil { - t.Fatal(err) - } - var profile struct { - ToolEnvFile string `json:"toolEnvFile"` - } - if err := json.Unmarshal(raw, &profile); err != nil { - t.Fatal(err) - } - if profile.ToolEnvFile != filepath.Join(initialization, "tool-env.json") { - t.Fatal("native profile did not retain the Runtime snapshot reference") - } - // Assert the actual env-selected HTTP credential, not a manually injected token. - headers, ok := opts.MCP[1]["headers"].([]map[string]string) - if !ok || len(headers) != 1 || headers[0]["name"] != "Authorization" || headers[0]["value"] != "Bearer fixture-bearer-canary" { - t.Fatal("frozen MCP credential was lost or replaced") - } - if err := filepath.WalkDir(opts.DataDir, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return nil - } - body, err := os.ReadFile(path) - if err != nil { - return err - } - if bytes.Contains(body, []byte("fixture-bearer-canary")) || bytes.Contains(body, []byte("fixture-tool-canary")) { - t.Fatal("tool credential copied into native persisted state") - } - return nil - }); err != nil { - t.Fatal(err) - } - } - if err := os.Remove(filepath.Join(initialization, "tool-env.json")); err != nil { - t.Fatal(err) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - configured, err := binding.Configure(req) - if err != nil { - t.Fatal(err) - } - if _, err := binding.Prepare(t.Context(), configured); err == nil { - t.Fatal("missing frozen credential source was recreated or fell back to anonymous") - } -} - -func TestWorkspaceRejectsInvalidToolEnvironment(t *testing.T) { - config, req, _ := workspaceFixture(t) - file := filepath.Join(t.TempDir(), "tool-env.json") - if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { - t.Fatal("invalid explicit tool configuration was ignored") - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_observations.go b/apps/parsar-daemon/internal/agent/mcode/tool_observations.go deleted file mode 100644 index 2811eb449..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/tool_observations.go +++ /dev/null @@ -1,63 +0,0 @@ -package mcode - -import ( - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (s *Session) emitToolStage(update toolUpdate, stage string) error { - payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} - if stage == "after" { - payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} - } - if s.req.ObserveToolObservations { - payload.Observation = workspaceToolObservation(update, stage) - if payload.Observation == nil { - var err error - payload.Observation, err = environmentMCPObservation(update, stage) - if err != nil { - return err - } - } - // Native task/skill bookkeeping has no qualified public item mapping. - if payload.Observation == nil { - return nil - } - } - s.emit(proto.TypeToolCall, payload) - return nil -} - -func workspaceToolObservation(update toolUpdate, stage string) *proto.ToolObservation { - if update.Name != "mcp__oac_workspace__workspace_bash" { - return nil - } - command, _ := update.RawInput["command"].(string) - if command == "" { - return nil - } - cwd := "/workspace" - result := &proto.ToolObservation{Kind: "command", Command: command, Cwd: &cwd, Status: "in_progress"} - if stage == "after" { - result.Status = update.Status - // ACP reports MCP content but no structured exit code or duration. - raw, _ := json.Marshal(update.RawOutput) - var output struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - } - if json.Unmarshal(raw, &output) == nil && output.Content != nil { - text := "" - for _, part := range output.Content { - if part.Type == "text" { - text += part.Text - } - } - result.Output, _ = json.Marshal(text) - } - } - return result -} diff --git a/apps/parsar-daemon/internal/agent/mcode/unsupported.go b/apps/parsar-daemon/internal/agent/mcode/unsupported.go deleted file mode 100644 index 2940b556a..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/unsupported.go +++ /dev/null @@ -1,48 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { - return fmt.Errorf("%w: native public function tools are not qualified", agent.ErrUnsupportedOperation) -} - -func (s *executor) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -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 -} - -func (s *prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/parsar-daemon/internal/agent/mcode/unsupported_test.go b/apps/parsar-daemon/internal/agent/mcode/unsupported_test.go deleted file mode 100644 index 8f8ca34d7..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/unsupported_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package mcode - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// 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") - } - } - var turn *Session - check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { - result, err := owner.ReadWorkspaceFile(ctx, secret, 1) - check(err) - if len(result.Data) != 0 || result.Truncated { - t.Fatal("unsupported read fabricated data") - } - } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { - result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) - check(err) - if len(result.Entries) != 0 || result.Truncated { - t.Fatal("unsupported listing fabricated entries") - } - } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { - result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) - check(err) - if result.SizeBytes != 0 { - t.Fatal("unsupported write fabricated receipt") - } - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/version.go b/apps/parsar-daemon/internal/agent/mcode/version.go deleted file mode 100644 index a23600bf7..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/version.go +++ /dev/null @@ -1,24 +0,0 @@ -package mcode - -import ( - "context" - "errors" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" -) - -var ErrCLINotFound = errors.New("mcode CLI not found") - -const SupportedVersion = "0.4.12" - -func defaultBinary() string { return binpath.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}) - if err == nil && version != SupportedVersion { - err = fmt.Errorf("mcode: unsupported version %s; install %s", version, SupportedVersion) - } - return version, err -} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go deleted file mode 100644 index 6372bc059..000000000 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ /dev/null @@ -1,163 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "runtime" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -// WorkspaceConfig is frozen deployment input, separate from public Agent options. -type WorkspaceConfig struct { - Binary, Node, Bridge, Directory, Network, Scratch string - AllowedDomains []string -} - -func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy) (WorkspaceConfig, error) { - c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), - Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} - if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { - return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") - } - if network.Validate() != nil { - return c, fmt.Errorf("mcode: explicit workspace network policy is required") - } - for _, path := range []string{binary, node, bridge, root, workspace} { - if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { - return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") - } - } - for _, path := range []string{binary, node, bridge} { - info, err := os.Stat(path) - if err != nil || !info.Mode().IsRegular() { - return c, fmt.Errorf("mcode: runtime program unavailable") - } - } - bound, err := os.Stat(workspace) - if err != nil || !bound.IsDir() { - return c, fmt.Errorf("mcode: workspace directory unavailable") - } - - if err := os.MkdirAll(c.Scratch, 0700); err != nil { - return c, err - } - return c, nil -} - -func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { - if c.Network != "enabled" || len(c.AllowedDomains) != 0 { - return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") - } - if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { - return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") - } - servers, err := runtimeMCP(req) - if err != nil { - return launchOptions{}, err - } - // Reuse public option validation and private Session state provisioning. - // The native process, ACP Session and workspace tools share the declared cwd. - private := req - private.LocalEnvironment, private.WorkDir, private.DisableExecutionEnvironment = nil, "", true - // Public declarations have already been resolved into the transient ACP map. - private.MCPHTTPServers = nil - opts, err := prepareOptionsWithSkills(ctx, private, false) - if err != nil { - return opts, err - } - opts.Dir = c.Directory - if len(req.LocalEnvironment.Skills) > 0 { - root := filepath.Join(opts.DataDir, "skills") - if err := os.MkdirAll(root, 0700); err != nil { - return opts, err - } - for _, skill := range req.LocalEnvironment.Skills { - link, target := filepath.Join(root, skill.Metadata.Name), localworkspace.SkillPath(skill) - actual, err := os.Readlink(link) - if err == nil { - if actual != target { - return opts, fmt.Errorf("mcode: unexpected native Skill root") - } - } else if !os.IsNotExist(err) { - return opts, err - } else if err := os.Symlink(target, link); err != nil { - return opts, err - } - } - } - - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) - if err != nil { - return opts, err - } - var config map[string]any - if err = json.Unmarshal(raw, &config); err != nil { - return opts, err - } - config["permissionMode"] = "bypassPermissions" - config["sandbox"] = map[string]bool{"enabled": false} - if len(req.LocalEnvironment.Skills) > 0 { - selected := config["agents"].(map[string]any)["default"].(map[string]any) - names := make([]string, 0, len(req.LocalEnvironment.Skills)) - for _, skill := range req.LocalEnvironment.Skills { - names = append(names, skill.Metadata.Name) - } - selected["skills"] = names - for _, key := range []string{"tools", "builtinTools"} { - selected[key] = append(selected[key].([]any), "skill") - } - } - raw, err = json.Marshal(config) - if err != nil { - return opts, err - } - if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { - return opts, err - } - profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} - - profile["workspace"] = c.Directory - { - file, err := localworkspace.ToolEnvironmentFile() - if err != nil { - return opts, err - } - if file != "" { - profile["toolEnvFile"] = file - } - } - - raw, err = json.Marshal(profile) - if err != nil { - return opts, err - } - path := filepath.Join(opts.DataDir, "workspace-profile.json") - if err = os.WriteFile(path, raw, 0600); err != nil { - return opts, err - } - opts.MCP = []map[string]any{{"name": "oac_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} - opts.MCP = append(opts.MCP, servers...) - if !req.DisableSubagents { - // This native data directory belongs to one public Session and its - // descendants. ACP's ephemeral server map otherwise covers only root. - configured := map[string]any{} - for _, server := range opts.MCP[:1] { - name, _ := server["name"].(string) - configured[name] = map[string]any{"type": "stdio", "command": server["command"], "args": server["args"], "env": map[string]string{}, "enabled": true} - } - raw, err := json.Marshal(map[string]any{"mcpServers": configured}) - if err != nil { - return opts, err - } - if err := os.WriteFile(filepath.Join(opts.DataDir, "mcp.json"), raw, 0o600); err != nil { - return opts, err - } - } - return opts, nil -} diff --git a/apps/parsar-daemon/internal/agent/opencode/export_test.go b/apps/parsar-daemon/internal/agent/opencode/export_test.go deleted file mode 100644 index 38ec5218a..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/export_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package opencode - -import ( - "context" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type SessionConfigForTest struct { - OpenCodeBinary string - ExtraArgs []string - KillTimeout time.Duration -} - -func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { - return newSession(ctx, req, out, sessionConfig{ - opencodeBinary: cfg.OpenCodeBinary, - extraArgs: cfg.ExtraArgs, - killTimeout: cfg.KillTimeout, - }) -} - -type Translator translator - -type Translation = translation - -func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) -} diff --git a/apps/parsar-daemon/internal/agent/opencode/options.go b/apps/parsar-daemon/internal/agent/opencode/options.go deleted file mode 100644 index 796f177a0..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/options.go +++ /dev/null @@ -1,312 +0,0 @@ -package opencode - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" -) - -// BuildResult is the opencode CLI launch plan for one prompt. -type BuildResult struct { - Args []string - Env []string - WorkDir string - ModelSelector string - Cleanup func() -} - -// BuildArgs translates the daemon prompt_request into an `opencode -// run` invocation. -func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, error) { - cleanup := func() {} - result := BuildResult{Cleanup: cleanup} - - resolvedWorkDir, err := resolveWorkDir(workDir) - if err != nil { - return result, err - } - - promptText, err := buildPrompt(prompt, opts) - if err != nil { - return result, err - } - - args := []string{"run", "--format", "json"} - if resolvedWorkDir != "" { - args = append(args, "--dir", resolvedWorkDir) - } - if model := firstString(opts, "model_selector", "model"); model != "" { - args = append(args, "--model", model) - result.ModelSelector = model - } - if agent := stringOpt(opts, "agent"); agent != "" { - args = append(args, "--agent", agent) - } - if boolOpt(opts, "dangerously_skip_permissions") { - args = append(args, "--dangerously-skip-permissions") - } - args = append(args, promptText) - - env, err := buildEnv(opts) - if err != nil { - return result, err - } - - rawConfig := stringOpt(opts, "opencode_json") - if servers, ok := opts["mcp_servers"]; ok && servers != nil { - rawConfig, err = mergeMCPConfig(rawConfig, servers) - if err != nil { - return result, err - } - } - if rawConfig != "" { - configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) - if err != nil { - return result, err - } - cleanup = scratchCleanup - env = append(env, "XDG_CONFIG_HOME="+configHome) - } - - result.Args = args - result.Env = env - result.WorkDir = resolvedWorkDir - result.Cleanup = cleanup - return result, nil -} - -func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { - config := map[string]any{} - if strings.TrimSpace(rawConfig) != "" { - if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { - return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) - } - } - servers, ok := rawServers.(map[string]any) - if !ok { - return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) - } - mcp, _ := config["mcp"].(map[string]any) - if mcp == nil { - mcp = map[string]any{} - } - for name, raw := range servers { - entry, ok := raw.(map[string]any) - if !ok { - return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) - } - enabled := true - if value, ok := entry["enabled"].(bool); ok { - enabled = value - } - if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { - remote := map[string]any{ - "type": "remote", - "url": strings.TrimSpace(remoteURL), - "enabled": enabled, - } - if headers := stringMap(entry["headers"]); len(headers) > 0 { - remote["headers"] = headers - } - mcp[name] = remote - continue - } - command, ok := entry["command"].(string) - if !ok || strings.TrimSpace(command) == "" { - return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) - } - commandParts := []string{command} - if args, ok := entry["args"].([]any); ok { - for _, arg := range args { - if value, ok := arg.(string); ok { - commandParts = append(commandParts, value) - } - } - } else if args, ok := entry["args"].([]string); ok { - commandParts = append(commandParts, args...) - } - local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} - if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { - local["environment"] = env - } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { - local["environment"] = env - } - mcp[name] = local - } - if len(mcp) > 0 { - config["mcp"] = mcp - } - encoded, err := json.Marshal(config) - if err != nil { - return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) - } - return string(encoded), nil -} - -func stringMap(value any) map[string]string { - switch typed := value.(type) { - case map[string]string: - return typed - case map[string]any: - result := make(map[string]string, len(typed)) - for key, raw := range typed { - if text, ok := raw.(string); ok { - result[key] = text - } - } - return result - default: - return nil - } -} - -func resolveWorkDir(input string) (string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", nil - } - var abs string - switch { - case strings.HasPrefix(trimmed, "~/"): - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("opencode: resolve home dir: %w", err) - } - abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - case filepath.IsAbs(trimmed): - abs = trimmed - default: - return "", fmt.Errorf("opencode: work_dir must be absolute or start with ~/, got %q", trimmed) - } - // Match claudecode + codex: mkdir -p so the wizard's "Created if - // it does not exist" hint actually holds across engines. - if err := os.MkdirAll(abs, 0o755); err != nil { - return "", fmt.Errorf("opencode: mkdir work_dir %s: %w", abs, err) - } - return abs, nil -} - -func buildPrompt(prompt string, opts map[string]any) (string, error) { - prompt = strings.TrimSpace(prompt) - if prompt == "" { - return "", fmt.Errorf("opencode: empty prompt") - } - systemPrompt := stringOpt(opts, "system_prompt") - if override := stringOpt(opts, "override_system_prompt"); override != "" { - systemPrompt = override - } - if systemPrompt == "" { - return prompt, nil - } - return systemPrompt + "\n\n" + prompt, nil -} - -func buildEnv(opts map[string]any) ([]string, error) { - env := []string{ - "DISABLE_TELEMETRY=1", - } - raw, ok := opts["env"] - if !ok || raw == nil { - return env, nil - } - envMap, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) - } - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - return env, nil -} - -func writeConfigHome(runID, raw string) (string, func(), error) { - if strings.TrimSpace(runID) == "" { - return "", func() {}, fmt.Errorf("opencode: runID required for scratch config") - } - var parsed any - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - return "", func() {}, fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) - } - root, err := paths.Root() - if err != nil { - return "", func() {}, err - } - scratchRoot := filepath.Join(root, "daemon", "scratch", safeRunID(runID)) - configHome := filepath.Join(scratchRoot, "config-home") - opencodeDir := filepath.Join(configHome, "opencode") - if err := os.MkdirAll(opencodeDir, 0o700); err != nil { - return "", func() {}, fmt.Errorf("opencode: create config dir %s: %w", opencodeDir, err) - } - configPath := filepath.Join(opencodeDir, "opencode.json") - if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { - return "", func() {}, fmt.Errorf("opencode: write %s: %w", configPath, err) - } - cleanup := func() { _ = os.RemoveAll(scratchRoot) } - return configHome, cleanup, nil -} - -func safeRunID(runID string) string { - var b strings.Builder - for _, r := range runID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "" { - return "run" - } - return out -} - -func firstString(opts map[string]any, keys ...string) string { - for _, key := range keys { - if v := stringOpt(opts, key); v != "" { - return v - } - } - return "" -} - -func stringOpt(opts map[string]any, key string) string { - if opts == nil { - return "" - } - v, ok := opts[key] - if !ok || v == nil { - return "" - } - s, ok := v.(string) - if !ok { - return "" - } - return strings.TrimSpace(s) -} - -func boolOpt(opts map[string]any, key string) bool { - if opts == nil { - return false - } - v, ok := opts[key] - if !ok || v == nil { - return false - } - b, ok := v.(bool) - return ok && b -} diff --git a/apps/parsar-daemon/internal/agent/opencode/options_test.go b/apps/parsar-daemon/internal/agent/opencode/options_test.go deleted file mode 100644 index 0b8bb2b3c..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/options_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package opencode_test - -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" -) - -func TestBuildArgsUsesOpenCodeRunJSONAndWorkdir(t *testing.T) { - res, err := opencode.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ - "model_selector": "anthropic/claude-opus-4-7", - "agent": "build", - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--format", "json") || !slices.Contains(res.Args, "run") { - t.Fatalf("args missing run/json: %v", res.Args) - } - if !containsPair(res.Args, "--dir", os.TempDir()) { - t.Fatalf("args missing --dir temp: %v", res.Args) - } - if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { - t.Fatalf("args missing model selector: %v", res.Args) - } - if !containsPair(res.Args, "--agent", "build") { - t.Fatalf("args missing agent: %v", res.Args) - } - if got := res.Args[len(res.Args)-1]; got != "hello" { - t.Fatalf("last arg prompt = %q, want hello; args=%v", got, res.Args) - } -} - -func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { - _, err := opencode.BuildArgs("run-1", "hello", "./relative", nil) - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) - } -} - -// TestBuildArgsCreatesMissingWorkdir: align with claudecode + codex — -// a non-existent absolute path is mkdir -p'd. Pinning this keeps the -// wizard's "Created if it does not exist" hint honest across engines. -func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - res, err := opencode.BuildArgs("run-1", "hello", target, nil) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--dir", target) { - t.Fatalf("args missing --dir %s: %v", target, res.Args) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -func TestBuildArgsWritesManagedConfigUnderOpenAgentCoreHome(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - res, err := opencode.BuildArgs("run/id", "hello", "", map[string]any{ - "opencode_json": `{"provider":{},"permission":{"*":"allow"}}`, - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - configHome := envValue(res.Env, "XDG_CONFIG_HOME") - if configHome == "" { - t.Fatalf("XDG_CONFIG_HOME missing in env: %v", res.Env) - } - wantPrefix := filepath.Join(home, "daemon", "scratch", "run_id", "config-home") - if configHome != wantPrefix { - t.Fatalf("configHome = %q, want %q", configHome, wantPrefix) - } - configPath := filepath.Join(configHome, "opencode", "opencode.json") - if _, err := os.Stat(configPath); err != nil { - t.Fatalf("expected opencode.json at %s: %v", configPath, err) - } - res.Cleanup() - if _, err := os.Stat(filepath.Join(home, "daemon", "scratch", "run_id")); !os.IsNotExist(err) { - t.Fatalf("scratch dir still exists after cleanup: %v", err) - } -} - -func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ - "opencode_json": `{"provider":{}}`, - "mcp_servers": map[string]any{ - "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, - "docs": map[string]any{ - "url": "https://docs.example.com/mcp", - "headers": map[string]any{"Authorization": "Bearer token"}, - }, - }, - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var config map[string]any - if err := json.Unmarshal(body, &config); err != nil { - t.Fatal(err) - } - mcp := config["mcp"].(map[string]any) - remote := mcp["docs"].(map[string]any) - if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { - t.Fatalf("remote = %+v", remote) - } - headers := remote["headers"].(map[string]any) - if headers["Authorization"] != "Bearer token" { - t.Fatalf("headers = %+v", headers) - } - local := mcp["local"].(map[string]any) - if local["type"] != "local" { - t.Fatalf("local = %+v", local) - } -} - -func TestBuildArgsRejectsBadEnvShape(t *testing.T) { - _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) - if err == nil || !strings.Contains(err.Error(), "env") { - t.Fatalf("BuildArgs env err = %v, want env shape error", err) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} - -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if strings.HasPrefix(item, prefix) { - return strings.TrimPrefix(item, prefix) - } - } - return "" -} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser.go b/apps/parsar-daemon/internal/agent/opencode/parser.go deleted file mode 100644 index 9231dff01..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/parser.go +++ /dev/null @@ -1,296 +0,0 @@ -package opencode - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "sync/atomic" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type translator struct { - runID string - seq atomic.Uint64 - - deltaBuf strings.Builder - plainBuf strings.Builder - rawLines []string - usage proto.Usage - stepUsage usageInfo - toolsSeen map[string]bool -} - -type translation struct { - Envelopes []proto.Envelope -} - -func newTranslator(runID string) *translator { return &translator{runID: runID} } - -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - t.rawLines = append(t.rawLines, string(line)) - - var head struct { - Type string `json:"type"` - Properties json.RawMessage `json:"properties"` - Part json.RawMessage `json:"part"` - } - if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { - if t.plainBuf.Len() > 0 { - t.plainBuf.WriteByte('\n') - } - t.plainBuf.Write(line) - return translation{}, nil - } - - switch head.Type { - case "message.part.delta": - return t.translatePartDelta(head.Properties) - case "text": - return t.translateTextPart(head.Part) - case "tool_use": - return t.translateToolPart(head.Part) - case "message.updated", "message.updated.1": - t.captureUsage(head.Properties) - return translation{}, nil - case "step_finish": - t.capturePartUsage(head.Part) - return translation{}, nil - default: - t.captureGenericUsage(line) - return translation{}, nil - } -} - -func (t *translator) translateToolPart(raw json.RawMessage) (translation, error) { - var p struct { - Type string `json:"type"` - CallID string `json:"callID"` - Tool string `json:"tool"` - State struct { - Status string `json:"status"` - Input map[string]any `json:"input"` - Output string `json:"output"` - Error string `json:"error"` - } `json:"state"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse tool part: %w", err) - } - if p.Type != "tool" || p.CallID == "" || p.Tool == "" || - (p.State.Status != "completed" && p.State.Status != "error") || t.toolsSeen[p.CallID] { - return translation{}, nil - } - result := map[string]any{"output": p.State.Output, "is_error": p.State.Status == "error"} - if p.State.Status == "error" { - result["output"] = p.State.Error - } - // JSON CLI tool parts arrive only after execution. Pair the call and - // result for existing trace consumers; neither frame requests approval. - call := proto.ToolCallPayload{ID: p.CallID, Name: p.Tool, Stage: "before", Args: p.State.Input} - before, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) - if err != nil { - return translation{}, err - } - call.Stage, call.Result = "after", result - after, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) - if err != nil { - return translation{}, err - } - if t.toolsSeen == nil { - t.toolsSeen = make(map[string]bool) - } - t.toolsSeen[p.CallID] = true - return translation{Envelopes: []proto.Envelope{before, after}}, nil -} - -func (t *translator) translatePartDelta(raw json.RawMessage) (translation, error) { - var p struct { - Field string `json:"field"` - Delta string `json:"delta"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse message.part.delta: %w", err) - } - if p.Delta == "" || (p.Field != "" && p.Field != "text") { - return translation{}, nil - } - t.deltaBuf.WriteString(p.Delta) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Delta, Sequence: t.seq.Add(1)}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateTextPart(raw json.RawMessage) (translation, error) { - var p struct { - Type string `json:"type"` - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse text part: %w", err) - } - if p.Text == "" || (p.Type != "" && p.Type != "text") { - return translation{}, nil - } - t.deltaBuf.WriteString(p.Text) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Text, Sequence: t.seq.Add(1)}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) captureUsage(raw json.RawMessage) { - var p struct { - Info usageInfo `json:"info"` - } - if err := json.Unmarshal(raw, &p); err == nil { - t.mergeUsage(p.Info) - } -} - -func (t *translator) capturePartUsage(raw json.RawMessage) { - var p usageInfo - if err := json.Unmarshal(raw, &p); err == nil { - if p.Tokens.CacheRead == 0 { - p.Tokens.CacheRead = p.Tokens.Cache.Read - } - if p.Tokens.CacheWrite == 0 { - p.Tokens.CacheWrite = p.Tokens.Cache.Write - } - t.stepUsage.Tokens.Input += p.Tokens.Input - t.stepUsage.Tokens.Output += p.Tokens.Output - t.stepUsage.Tokens.Reasoning += p.Tokens.Reasoning - t.stepUsage.Tokens.CacheRead += p.Tokens.CacheRead - t.stepUsage.Tokens.CacheWrite += p.Tokens.CacheWrite - t.stepUsage.Tokens.Total += p.Tokens.Total - t.stepUsage.Cost += p.Cost - t.mergeUsage(t.stepUsage) - } -} - -func (t *translator) captureGenericUsage(raw json.RawMessage) { - var p struct { - Info usageInfo `json:"info"` - Tokens usageTokens `json:"tokens"` - Cost float64 `json:"cost"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return - } - t.mergeUsage(p.Info) - if p.Tokens.Input != 0 || p.Tokens.Output != 0 || p.Cost != 0 { - t.mergeUsage(usageInfo{Tokens: p.Tokens, Cost: p.Cost}) - } -} - -type usageInfo struct { - Tokens usageTokens `json:"tokens"` - Cost float64 `json:"cost"` -} - -type usageTokens struct { - Input int32 `json:"input"` - Output int32 `json:"output"` - Reasoning int32 `json:"reasoning"` - CacheRead int32 `json:"cacheRead"` - CacheWrite int32 `json:"cacheWrite"` - Total int32 `json:"total"` - Cache struct { - Read int32 `json:"read"` - Write int32 `json:"write"` - } `json:"cache"` -} - -func (t *translator) mergeUsage(info usageInfo) { - if info.Tokens.CacheRead == 0 { - info.Tokens.CacheRead = info.Tokens.Cache.Read - } - if info.Tokens.CacheWrite == 0 { - info.Tokens.CacheWrite = info.Tokens.Cache.Write - } - if info.Tokens.Input != 0 { - t.usage.InputTokens = info.Tokens.Input - } - if info.Tokens.Output != 0 { - t.usage.OutputTokens = info.Tokens.Output - } - if info.Cost != 0 { - t.usage.CostUSD = info.Cost - } - if info.Tokens.Reasoning != 0 || info.Tokens.CacheRead != 0 || info.Tokens.CacheWrite != 0 || info.Tokens.Total != 0 { - if t.usage.Raw == nil { - t.usage.Raw = map[string]any{} - } - if info.Tokens.Reasoning != 0 { - t.usage.Raw["reasoning_tokens"] = info.Tokens.Reasoning - } - if info.Tokens.CacheRead != 0 { - t.usage.Raw["cache_read_tokens"] = info.Tokens.CacheRead - } - if info.Tokens.CacheWrite != 0 { - t.usage.Raw["cache_write_tokens"] = info.Tokens.CacheWrite - } - if info.Tokens.Total != 0 { - t.usage.Raw["total_tokens"] = info.Tokens.Total - } - } -} - -func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - var envs []proto.Envelope - if t.plainBuf.Len() > 0 && t.deltaBuf.Len() == 0 { - delta := strings.TrimSpace(t.plainBuf.String()) - if delta != "" { - if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: delta, Sequence: t.seq.Add(1)}); err == nil { - envs = append(envs, env) - } - t.deltaBuf.WriteString(delta) - } - } - usage := t.usage - usage.Provider = "opencode" - if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { - if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { - envs = append(envs, env) - } - } - if waitErr != nil || cancelled { - msg := "opencode: subprocess exited without success" - if waitErr != nil { - msg = fmt.Sprintf("opencode: subprocess exited: %v", waitErr) - } - if strings.TrimSpace(stderr) != "" { - msg += ": " + truncate(strings.TrimSpace(stderr), 400) - } - if cancelled { - msg = "opencode: cancelled" - } - if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { - envs = append(envs, env) - } - } - content := strings.TrimSpace(t.deltaBuf.String()) - metadata := map[string]any{"connector_path": "opencode_run"} - if len(t.rawLines) > 0 { - metadata["opencode_raw_lines"] = t.rawLines - } - if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{Content: content, Transcript: strings.Join(t.rawLines, "\n"), Usage: usage, Metadata: metadata}); err == nil { - envs = append(envs, env) - } - return envs -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] -} diff --git a/apps/parsar-daemon/internal/agent/opencode/parser_test.go b/apps/parsar-daemon/internal/agent/opencode/parser_test.go deleted file mode 100644 index b09f38a3e..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/parser_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package opencode_test - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestTranslatePartDeltaEmitsDeltaAndDone(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message.part.delta","properties":{"field":"text","delta":"hello"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "hello" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } - envs := tr.TerminalEnvelopes(nil, "", false) - last := envs[len(envs)-1] - if last.Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done", last.Type) - } - done := decodePayload[proto.DonePayload](t, last) - if done.Content != "hello" || done.Metadata["connector_path"] != "opencode_run" { - t.Fatalf("done payload = %#v", done) - } -} - -func TestTranslateTextEventsEmitDeltasAndDone(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-text") - tx, err := tr.Translate([]byte(`{"type":"text","timestamp":1785838824775,"sessionID":"ses_1","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_1","type":"text","text":"OK"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "OK" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } - if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_2","type":"step-finish"}}`)); err != nil { - t.Fatalf("Translate step finish: %v", err) - } - tx, err = tr.Translate([]byte(`{"type":"text","timestamp":1785838824776,"sessionID":"ses_1","part":{"id":"prt_3","messageID":"msg_1","sessionID":"ses_1","type":"text","text":" again"}}`)) - if err != nil { - t.Fatalf("Translate second text: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("second delta envelopes = %#v", tx.Envelopes) - } - second := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if second.Delta != " again" || second.Sequence <= delta.Sequence { - t.Fatalf("second delta payload = %#v", second) - } - if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_4","type":"step-finish"}}`)); err != nil { - t.Fatalf("Translate second step finish: %v", err) - } - - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "OK again" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestTranslateCapturesUsage(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-u") - _, err := tr.Translate([]byte(`{"type":"message.updated","properties":{"info":{"cost":0.25,"tokens":{"input":10,"output":7,"reasoning":3,"cacheRead":2,"cacheWrite":1,"total":23}}}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.Provider != "opencode" || got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.25 { - t.Fatalf("usage = %#v", got) - } - if got.Raw["total_tokens"] != float64(23) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -func TestTranslateStepFinishCapturesUsage(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-step-finish") - _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":12576,"input":11803,"output":645,"reasoning":0,"cache":{"write":4,"read":128}},"cost":0.00432258}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":25,"input":20,"output":3,"reasoning":2,"cache":{"write":1,"read":4}},"cost":0.001}}`)) - if err != nil { - t.Fatalf("Translate second step: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.InputTokens != 11823 || got.OutputTokens != 648 || got.CostUSD != 0.00532258 { - t.Fatalf("usage = %#v", got) - } - if got.Raw["total_tokens"] != float64(12601) || got.Raw["reasoning_tokens"] != float64(2) || got.Raw["cache_read_tokens"] != float64(132) || got.Raw["cache_write_tokens"] != float64(5) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -func TestPlainOutputFallsBackToDelta(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-p") - _, _ = tr.Translate([]byte("plain output")) - envs := tr.TerminalEnvelopes(nil, "", false) - if len(envs) < 2 || envs[0].Type != proto.TypeDelta || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("plain terminal envs = %#v", envs) - } - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "plain output" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestTerminalErrorIncludesStderr(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-e") - envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) - if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("error terminal envs = %#v", envs) - } - errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) - if errPayload.Error == "" || !contains(errPayload.Error, "bad auth") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func decodePayload[T any](t *testing.T, env proto.Envelope) T { - t.Helper() - var out T - if err := json.Unmarshal(env.Payload, &out); err != nil { - t.Fatalf("decode %s payload: %v", env.Type, err) - } - return out -} - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return sub == "" -} diff --git a/apps/parsar-daemon/internal/agent/opencode/session.go b/apps/parsar-daemon/internal/agent/opencode/session.go deleted file mode 100644 index 622b33abe..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/session.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package opencode is the agent_kind="opencode" adapter. It drives the -// OpenCode CLI via `opencode run --format json`. -package opencode - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "strings" - "sync" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -type sessionConfig struct { - opencodeBinary string - extraArgs []string - killTimeout time.Duration - logger *slog.Logger -} - -func defaultConfig() sessionConfig { - return sessionConfig{opencodeBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} -} - -// Factory implements agent.Factory for agent_kind="opencode". -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `opencode run` subprocess. -type Session struct { - runID string - model string - cfg sessionConfig - - cmd *exec.Cmd - out chan<- proto.Envelope - - cancelCtx context.Context - cancelFn context.CancelFunc - - cancelOnce sync.Once - closeOutOnce sync.Once - waitDone chan struct{} - cleanup func() - - stderrMu sync.Mutex - stderr bytes.Buffer -} - -var _ agent.Session = (*Session)(nil) - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("opencode: nil out channel") - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.opencodeBinary == "" { - cfg.opencodeBinary = defaultBinary() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - opts, err := prepareManagedSkills(parent, cfg.logger, req) - if err != nil { - return nil, err - } - - prompt, err := req.Input.TextOnly() - if err != nil { - return nil, err - } - buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts) - if err != nil { - return nil, fmt.Errorf("opencode: build args: %w", err) - } - cancelCtx, cancelFn := context.WithCancel(parent) - - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - cmd := exec.CommandContext(cancelCtx, cfg.opencodeBinary, args...) - if buildRes.WorkDir != "" { - cmd.Dir = buildRes.WorkDir - } - cmd.Env = append(os.Environ(), buildRes.Env...) - - stdout, err := cmd.StdoutPipe() - if err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: start %q: %w", cfg.opencodeBinary, err) - } - model := buildRes.ModelSelector - if _, key, qualified := strings.Cut(model, "/"); qualified { - model = key - } - - s := &Session{ - runID: req.RunID, - model: model, - cfg: cfg, - cmd: cmd, - out: out, - cancelCtx: cancelCtx, - cancelFn: cancelFn, - waitDone: make(chan struct{}), - cleanup: buildRes.Cleanup, - } - go s.pumpStderr(stderr) - go s.run(stdout) - return s, nil -} - -func (s *Session) Cancel(context.Context) error { - s.cancelOnce.Do(func() { - if s.cmd.Process == nil { - return - } - _ = s.cmd.Process.Signal(syscall.SIGTERM) - go func() { - select { - case <-s.waitDone: - return - case <-time.After(s.cfg.killTimeout): - _ = s.cmd.Process.Signal(syscall.SIGKILL) - } - }() - s.cancelFn() - }) - return nil -} - -func (s *Session) run(stdout io.Reader) { - defer close(s.waitDone) - defer s.cleanup() - defer s.closeOut() - - tr := newTranslator(s.runID) - tr.usage.Model = s.model - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for sc.Scan() { - tx, err := tr.Translate(sc.Bytes()) - if err != nil { - s.cfg.logger.Warn("opencode: translate line", "run_id", s.runID, "err", err) - continue - } - for _, env := range tx.Envelopes { - select { - case s.out <- env: - case <-s.cancelCtx.Done(): - _ = s.cmd.Wait() - return - } - } - } - if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { - s.cfg.logger.Warn("opencode: scan stdout", "run_id", s.runID, "err", err) - } - - waitErr := s.cmd.Wait() - for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { - s.trySend(env) - } -} - -func (s *Session) pumpStderr(stderr io.Reader) { - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - for sc.Scan() { - line := sc.Text() - s.stderrMu.Lock() - if s.stderr.Len() > 0 { - s.stderr.WriteByte('\n') - } - s.stderr.WriteString(line) - s.stderrMu.Unlock() - s.cfg.logger.Warn("opencode stderr", "run_id", s.runID, "line", line) - } -} - -func (s *Session) stderrString() string { - s.stderrMu.Lock() - defer s.stderrMu.Unlock() - return s.stderr.String() -} - -func (s *Session) trySend(env proto.Envelope) { - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("opencode: terminal send timed out", "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/opencode/session_test.go b/apps/parsar-daemon/internal/agent/opencode/session_test.go deleted file mode 100644 index c5cf40436..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/session_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package opencode_test - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `opencode` when -// OPENCODE_TESTHELPER_ROLE is set, bypassing m.Run so the test -// framework's PASS line doesn't pollute fake stdout. -const opencodeHelperEnvKey = "OPENCODE_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(opencodeHelperEnvKey); role != "" { - runFakeOpenCode(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -func runFakeOpenCode(role string) { - if dumpPath := os.Getenv("OPENCODE_TESTHELPER_CONFIG_DUMP"); dumpPath != "" { - body, err := json.Marshal(map[string]string{ - "config_dir": os.Getenv("OPENCODE_CONFIG_DIR"), - "xdg_config_home": os.Getenv("XDG_CONFIG_HOME"), - }) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "encode managed config env: %v\n", err) - os.Exit(65) - } - if err := os.WriteFile(dumpPath, body, 0o600); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "dump managed config: %v\n", err) - os.Exit(65) - } - } - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - - sawRun := false - sawJSONFormat := false - for i, arg := range os.Args[1:] { - if arg == "run" { - sawRun = true - } - if arg == "--format" && i+2 <= len(os.Args[1:]) && os.Args[i+2] == "json" { - sawJSONFormat = true - } - } - - switch role { - case "json-success": - if !sawRun || !sawJSONFormat { - _, _ = os.Stderr.WriteString("missing opencode run --format json\n") - os.Exit(64) - } - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "hi ", - }, - }) - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "there", - }, - }) - _ = enc.Encode(map[string]any{ - "type": "message.updated", - "properties": map[string]any{ - "info": map[string]any{ - "cost": 0.12, - "tokens": map[string]any{ - "input": 4, - "output": 2, - "total": 6, - }, - }, - }, - }) - - case "plain-success": - _, _ = os.Stdout.WriteString("plain line one\nplain line two\n") - - case "nonzero": - _, _ = os.Stderr.WriteString("bad auth from fake opencode\n") - os.Exit(17) - - case "hang": - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "started", - }, - }) - time.Sleep(10 * time.Minute) - } -} - -func opencodeHelperConfig() opencode.SessionConfigForTest { - return opencode.SessionConfigForTest{ - OpenCodeBinary: os.Args[0], - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -func opencodeHelperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - opencodeHelperEnvKey: role, - }, - }, - } -} - -func drainOpenCode(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_json", "hello", "json-success"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeDelta) - mustContainOpenCode(t, types, proto.TypeUsage) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - for _, env := range got { - if env.ID != "run_json" { - t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) - } - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "hi there" { - t.Fatalf("done content = %q, want hi there", done.Content) - } - if done.Usage.Provider != "opencode" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { - t.Fatalf("done usage = %#v", done.Usage) - } -} - -func TestSessionInstallsAndRegistersManagedSkills(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - t.Setenv("OPENCODE_CONFIG_DIR", "") - userConfigHome := filepath.Join(t.TempDir(), "user-config") - t.Setenv("XDG_CONFIG_HOME", userConfigHome) - body := openCodeSkillZip(t) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(body) - })) - defer srv.Close() - - dumpPath := filepath.Join(t.TempDir(), "opencode.json") - out := make(chan proto.Envelope, 32) - req := opencodeHelperReq("run_skills", "hello", "json-success") - req.ConversationID = "conv-skills" - req.AgentStateKey = "conv-skills/agent-1/opencode" - req.AgentOptions["skills"] = []any{map[string]any{ - "name": "find-skills", "version": "1.0.0", "download_url": srv.URL, - "sha256": fmt.Sprintf("%x", sha256.Sum256(body)), - }} - req.AgentOptions["env"].(map[string]any)["OPENCODE_TESTHELPER_CONFIG_DUMP"] = dumpPath - - sess, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - if _, closed := drainOpenCode(t, out, 5*time.Second); !closed { - t.Fatal("out did not close") - } - - skillRoot := filepath.Join(home, "runtime", "opencode", "state", "conv-skills", "agent-1", "opencode", "skills") - if _, err := os.Stat(filepath.Join(skillRoot, "find-skills", "SKILL.md")); err != nil { - t.Fatalf("managed skill missing: %v", err) - } - dumped, err := os.ReadFile(dumpPath) - if err != nil { - t.Fatalf("read dumped config: %v", err) - } - var configEnv map[string]string - if err := json.Unmarshal(dumped, &configEnv); err != nil { - t.Fatalf("decode dumped config env: %v", err) - } - if got, want := configEnv["config_dir"], filepath.Dir(skillRoot); got != want { - t.Fatalf("OPENCODE_CONFIG_DIR = %q, want %q", got, want) - } - if got := configEnv["xdg_config_home"]; got != userConfigHome { - t.Fatalf("XDG_CONFIG_HOME = %q, want inherited %q", got, userConfigHome) - } - - cleanupReq := opencodeHelperReq("run_skills_cleanup", "hello", "json-success") - cleanupReq.ConversationID = req.ConversationID - cleanupReq.AgentStateKey = req.AgentStateKey - cleanupOut := make(chan proto.Envelope, 32) - cleanupSession, err := opencode.NewSessionForTest(context.Background(), cleanupReq, cleanupOut, opencodeHelperConfig()) - if err != nil { - t.Fatalf("cleanup session: %v", err) - } - defer cleanupSession.Cancel(context.Background()) - if _, closed := drainOpenCode(t, cleanupOut, 5*time.Second); !closed { - t.Fatal("cleanup out did not close") - } - if _, err := os.Stat(filepath.Join(skillRoot, "find-skills")); !os.IsNotExist(err) { - t.Fatalf("unbound skill still exists: %v", err) - } -} - -func openCodeSkillZip(t *testing.T) []byte { - t.Helper() - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - entry, err := writer.Create("SKILL.md") - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write([]byte("---\nname: find-skills\ndescription: Find skills\n---\nUse the catalog.")); err != nil { - t.Fatal(err) - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - return buffer.Bytes() -} - -func TestSessionPlainStdoutFallsBackToDeltaAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_plain", "hello", "plain-success"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - if len(got) < 2 || got[0].Type != proto.TypeDelta || got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("plain envs = %v", types) - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "plain line one\nplain line two" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_err", "hello", "nonzero"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeError) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "bad auth from fake opencode") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_cancel", "hello", "hang"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - // Let the helper emit its first delta before cancellation. - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeError) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } -} - -func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { - var session any = (*opencode.Session)(nil) - if _, ok := session.(agent.PermissionResponder); ok { - t.Fatal("unexpected permission responder") - } - if _, ok := session.(agent.UserChoiceResponder); ok { - t.Fatal("unexpected user-choice responder") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_nil", "hello", "json-success"), nil, opencodeHelperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - out := make(chan proto.Envelope, 4) - _, err := opencode.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, opencodeHelperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := opencodeHelperConfig() - cfg.OpenCodeBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_bad", "hello", "json-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func opencodeEnvTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, env := range envs { - out[i] = env.Type - } - return out -} - -func mustContainOpenCode(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Fatalf("expected %q in %v", needle, haystack) - } -} diff --git a/apps/parsar-daemon/internal/agent/opencode/skills.go b/apps/parsar-daemon/internal/agent/opencode/skills.go deleted file mode 100644 index 9a78ff441..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/skills.go +++ /dev/null @@ -1,134 +0,0 @@ -package opencode - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -const ( - openCodeConfigDirEnv = "OPENCODE_CONFIG_DIR" - openCodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" -) - -func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (map[string]any, error) { - rawSkills, hasSkills := req.AgentOptions["skills"] - if !hasSkills && strings.TrimSpace(req.AgentStateKey) == "" && strings.TrimSpace(req.ConversationID) == "" && strings.TrimSpace(req.RunID) == "" { - return req.AgentOptions, nil - } - root, err := agent.ManagedSkillsRoot("opencode", req.AgentStateKey, req.ConversationID, req.RunID) - if err != nil { - return nil, fmt.Errorf("opencode: resolve managed skills root: %w", err) - } - result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) - if err != nil { - return nil, fmt.Errorf("opencode: install skills: %w", err) - } - for _, warning := range result.Warnings { - logger.Warn("opencode: skill install warning", "run_id", req.RunID, "msg", warning) - } - if len(result.SkillDirs) == 0 { - return req.AgentOptions, nil - } - return withOpenCodeSkillRoot(req.AgentOptions, root) -} - -func withOpenCodeSkillRoot(opts map[string]any, root string) (map[string]any, error) { - out := make(map[string]any, len(opts)+1) - for key, value := range opts { - out[key] = value - } - env := map[string]any{} - if raw := opts["env"]; raw != nil { - existing, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) - } - for key, value := range existing { - env[key] = value - } - } - configDir, err := openCodeEnvValue(env, openCodeConfigDirEnv) - if err != nil { - return nil, err - } - if strings.TrimSpace(configDir) == "" { - env[openCodeConfigDirEnv] = filepath.Dir(root) - } else { - inline, err := openCodeEnvValue(env, openCodeConfigContentEnv) - if err != nil { - return nil, err - } - inline, err = addOpenCodeSkillPath(inline, root) - if err != nil { - return nil, err - } - env[openCodeConfigContentEnv] = inline - } - out["env"] = env - return out, nil -} - -func openCodeEnvValue(env map[string]any, key string) (string, error) { - if raw, ok := env[key]; ok { - value, ok := raw.(string) - if !ok { - return "", fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", key, raw) - } - return value, nil - } - return os.Getenv(key), nil -} - -func addOpenCodeSkillPath(rawConfig, root string) (string, error) { - config := map[string]any{} - if strings.TrimSpace(rawConfig) != "" { - if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { - return "", fmt.Errorf("opencode: %s must be valid JSON: %w", openCodeConfigContentEnv, err) - } - if config == nil { - config = map[string]any{} - } - } - skills, ok := config["skills"].(map[string]any) - if !ok && config["skills"] != nil { - return "", fmt.Errorf("opencode: %s skills must be object, got %T", openCodeConfigContentEnv, config["skills"]) - } - if skills == nil { - skills = map[string]any{} - } - paths := []any{} - if rawPaths := skills["paths"]; rawPaths != nil { - var ok bool - paths, ok = rawPaths.([]any) - if !ok { - return "", fmt.Errorf("opencode: %s skills.paths must be array, got %T", openCodeConfigContentEnv, rawPaths) - } - } - found := false - for _, path := range paths { - value, ok := path.(string) - if !ok { - return "", fmt.Errorf("opencode: %s skills.paths entries must be strings", openCodeConfigContentEnv) - } - found = found || value == root - } - if found { - return rawConfig, nil - } - skills["paths"] = append(paths, root) - config["skills"] = skills - body, err := json.Marshal(config) - if err != nil { - return "", fmt.Errorf("opencode: marshal %s: %w", openCodeConfigContentEnv, err) - } - return string(body), nil -} diff --git a/apps/parsar-daemon/internal/agent/opencode/version.go b/apps/parsar-daemon/internal/agent/opencode/version.go deleted file mode 100644 index 3e2b04728..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/version.go +++ /dev/null @@ -1,35 +0,0 @@ -package opencode - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" -) - -// InstallURL points operators at the OpenCode documentation when the -// daemon can see the adapter but not the CLI binary. -const InstallURL = "https://opencode.ai/docs" - -// defaultBinary is the executable to probe and spawn: binpath.OpenCode() -// honours the OAC_RUNTIME_OPENCODE_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.OpenCode() } - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary -// cannot be located on PATH. Callers use errors.Is to distinguish an -// install problem from a present-but-broken CLI. -var ErrCLINotFound = errors.New("opencode CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "opencode", - DefaultBinary: defaultBinary(), - MissingError: ErrCLINotFound, - TrimBinary: true, - }) -} diff --git a/apps/parsar-daemon/internal/agent/opencode/version_test.go b/apps/parsar-daemon/internal/agent/opencode/version_test.go deleted file mode 100644 index 21f6bf46a..000000000 --- a/apps/parsar-daemon/internal/agent/opencode/version_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package opencode_test - -import ( - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "opencode", - DefaultBinary: "opencode", - MissingError: opencode.ErrCLINotFound, - Check: opencode.CheckCLIAvailable, - WhitespaceDefaults: true, - }) -} diff --git a/apps/parsar-daemon/internal/agent/pi/export_test.go b/apps/parsar-daemon/internal/agent/pi/export_test.go deleted file mode 100644 index 229ce0021..000000000 --- a/apps/parsar-daemon/internal/agent/pi/export_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package pi - -import ( - "context" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type SessionConfigForTest struct { - PiBinary string - ExtraArgs []string - KillTimeout time.Duration -} - -func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { - return newSession(ctx, req, out, sessionConfig{ - piBinary: cfg.PiBinary, - extraArgs: cfg.ExtraArgs, - killTimeout: cfg.KillTimeout, - }) -} - -type Translator translator - -type Translation = translation - -func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) -} diff --git a/apps/parsar-daemon/internal/agent/pi/options_test.go b/apps/parsar-daemon/internal/agent/pi/options_test.go deleted file mode 100644 index 563b22862..000000000 --- a/apps/parsar-daemon/internal/agent/pi/options_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package pi_test - -import ( - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" -) - -func TestBuildArgsUsesModeJsonAndPromptLast(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ - "model": "anthropic/claude-opus-4-7", - "api_key": "sk-test", - "provider": "anthropic", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - if !containsPair(res.Args, "--mode", "json") { - t.Fatalf("args missing --mode json: %v", res.Args) - } - if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { - t.Fatalf("args missing --model: %v", res.Args) - } - // Secrets must never ride on argv (ps would leak them): a provided - // api_key is intentionally dropped here and delivered via env instead - // (see server injectPiManagedModel / OAC_RUNTIME_PI_API_KEY). - if slices.Contains(res.Args, "--api-key") || slices.Contains(res.Args, "sk-test") { - t.Fatalf("api_key must not leak onto argv: %v", res.Args) - } - if !containsPair(res.Args, "--provider", "anthropic") { - t.Fatalf("args missing --provider: %v", res.Args) - } - // pi's -p consumes the immediately-following arg as the prompt, so the - // prompt MUST be the final arg, preceded by -p. - n := len(res.Args) - if n < 2 || res.Args[n-2] != "-p" || res.Args[n-1] != "hello" { - t.Fatalf("expected args to end with -p hello, got %v", res.Args) - } - if res.WorkDir != os.TempDir() { - t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) - } -} - -func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "./relative", nil, "") - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) - } -} - -func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - res, err := pi.BuildArgs("run-1", "hello", target, nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if res.WorkDir != target { - t.Fatalf("WorkDir = %q, want %q", res.WorkDir, target) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -func TestBuildArgsSystemPromptAppends(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "system_prompt": "be terse", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--append-system-prompt", "be terse") { - t.Fatalf("args missing --append-system-prompt: %v", res.Args) - } - if slices.Contains(res.Args, "--system-prompt") { - t.Fatalf("system_prompt must map to append, not override: %v", res.Args) - } -} - -func TestBuildArgsOverrideSystemPromptReplaces(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "system_prompt": "be terse", - "override_system_prompt": "you are root", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--system-prompt", "you are root") { - t.Fatalf("args missing --system-prompt override: %v", res.Args) - } - // Override wins: the append we tentatively added must be stripped. - if slices.Contains(res.Args, "--append-system-prompt") { - t.Fatalf("override must strip the append: %v", res.Args) - } -} - -func TestBuildArgsResumeSessionUsesExplicitID(t *testing.T) { - sessionDir := filepath.Join(t.TempDir(), "sessions") - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "resume_session_id": "from-opts", - "session_dir": sessionDir, - }, "from-param") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--session-dir", sessionDir) { - t.Fatalf("args missing --session-dir: %v", res.Args) - } - if !containsPair(res.Args, "--session", "from-param") { - t.Fatalf("explicit resume id must win: %v", res.Args) - } -} - -func TestBuildArgsSessionDirCreatesAndAddsFlag(t *testing.T) { - sessionDir := filepath.Join(t.TempDir(), "missing", "sessions") - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "session_dir": sessionDir, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--session-dir", sessionDir) { - t.Fatalf("args missing --session-dir: %v", res.Args) - } - info, err := os.Stat(sessionDir) - if err != nil { - t.Fatalf("stat session_dir: %v", err) - } - if !info.IsDir() { - t.Fatalf("session_dir %q is not a directory", sessionDir) - } -} - -func TestBuildArgsRejectsRelativeSessionDir(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"session_dir": "./sessions"}, "") - if err == nil || !strings.Contains(err.Error(), "session_dir") { - t.Fatalf("BuildArgs session_dir err = %v, want session_dir error", err) - } -} - -func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "resume_session_id": "sess-42", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if slices.Contains(res.Args, "--session") || slices.Contains(res.Args, "sess-42") { - t.Fatalf("resume_session_id option must be ignored: %v", res.Args) - } -} - -func TestBuildArgsSkillDirsRepeatFlag(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "skill_dirs": []any{"/skills/a", "/skills/b"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--skill", "/skills/a") { - t.Fatalf("args missing first --skill: %v", res.Args) - } - if !containsPair(res.Args, "--skill", "/skills/b") { - t.Fatalf("args missing second --skill: %v", res.Args) - } -} - -func TestBuildArgsTelemetryOptOutEnv(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if envValue(res.Env, "PI_TELEMETRY") != "0" { - t.Fatalf("expected PI_TELEMETRY=0 opt-out, env=%v", res.Env) - } -} - -func TestBuildArgsPassesThroughEnvSorted(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "env": map[string]any{"BBB": "2", "AAA": "1"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if envValue(res.Env, "AAA") != "1" || envValue(res.Env, "BBB") != "2" { - t.Fatalf("env passthrough missing: %v", res.Env) - } -} - -func TestBuildArgsRejectsBadEnvShape(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}, "") - if err == nil || !strings.Contains(err.Error(), "env") { - t.Fatalf("BuildArgs env err = %v, want env shape error", err) - } -} - -func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { - _, err := pi.BuildArgs("run-1", " ", "", nil, "") - if err == nil || !strings.Contains(err.Error(), "prompt") { - t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} - -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if v, ok := strings.CutPrefix(item, prefix); ok { - return v - } - } - return "" -} diff --git a/apps/parsar-daemon/internal/agent/pi/parser.go b/apps/parsar-daemon/internal/agent/pi/parser.go deleted file mode 100644 index ac7d59bf9..000000000 --- a/apps/parsar-daemon/internal/agent/pi/parser.go +++ /dev/null @@ -1,337 +0,0 @@ -package pi - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "sync/atomic" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// translator converts one NDJSON line from pi's `--mode json` stdout -// into zero or more proto.Envelope frames. One translator lives per -// session. pi has no explicit terminal frame: the stream ends at process -// EOF, so the session pump calls terminalEnvelopes after cmd.Wait. -type translator struct { - runID string - seq atomic.Uint64 - - deltaBuf strings.Builder - finalText string - rawLines []string - usage proto.Usage - usageSet bool - sessionID string - failed bool -} - -type translation struct { - Envelopes []proto.Envelope - // SessionID is surfaced from the session header line so session.go - // can write it into binding metadata for --session resume. - SessionID string -} - -func newTranslator(runID string) *translator { return &translator{runID: runID} } - -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - t.rawLines = append(t.rawLines, string(line)) - - var head struct { - Type string `json:"type"` - } - // pi emits strict JSON per line; a non-JSON line is a stray log, not - // a fatal error — skip it to keep one bad line from killing the run. - if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { - return translation{}, nil - } - - switch head.Type { - case "session": - return t.translateSessionHeader(line) - case "message_update": - return t.translateMessageUpdate(line) - case "tool_execution_start": - return t.translateToolStart(line) - case "tool_execution_end": - return t.translateToolEnd(line) - case "message_end": - return t.translateMessageEnd(line) - default: - return translation{}, nil - } -} - -func (t *translator) translateSessionHeader(line []byte) (translation, error) { - var msg struct { - ID string `json:"id"` - } - _ = json.Unmarshal(line, &msg) - if msg.ID != "" { - t.sessionID = msg.ID - } - return translation{SessionID: msg.ID}, nil -} - -func (t *translator) translateMessageUpdate(line []byte) (translation, error) { - var msg struct { - Event struct { - Type string `json:"type"` - Delta string `json:"delta"` - } `json:"assistantMessageEvent"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse message_update: %w", err) - } - switch msg.Event.Type { - case "text_delta": - if msg.Event.Delta == "" { - return translation{}, nil - } - t.deltaBuf.WriteString(msg.Event.Delta) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: msg.Event.Delta, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - case "thinking_delta": - if msg.Event.Delta == "" { - return translation{}, nil - } - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: msg.Event.Delta, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - default: - return translation{}, nil - } -} - -func (t *translator) translateToolStart(line []byte) (translation, error) { - var msg struct { - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Args map[string]any `json:"args"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse tool_execution_start: %w", err) - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: msg.ToolCallID, - Name: msg.ToolName, - Stage: "before", - Args: msg.Args, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateToolEnd(line []byte) (translation, error) { - var msg struct { - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Result any `json:"result"` - IsError bool `json:"isError"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse tool_execution_end: %w", err) - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: msg.ToolCallID, - Name: msg.ToolName, - Stage: "after", - Result: map[string]any{ - "content": msg.Result, - "is_error": msg.IsError, - }, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -type piUsage struct { - Input int32 `json:"input"` - Output int32 `json:"output"` - CacheRead int32 `json:"cacheRead"` - CacheWrite int32 `json:"cacheWrite"` - CacheWrite1h int32 `json:"cacheWrite1h"` - Reasoning int32 `json:"reasoning"` - TotalTokens int32 `json:"totalTokens"` - Cost struct { - Total float64 `json:"total"` - } `json:"cost"` -} - -func (t *translator) translateMessageEnd(line []byte) (translation, error) { - var msg struct { - Message struct { - Role string `json:"role"` - Content []json.RawMessage - Provider string `json:"provider"` - Model string `json:"model"` - Usage piUsage `json:"usage"` - StopReason string `json:"stopReason"` - ErrorMessage string `json:"errorMessage"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse message_end: %w", err) - } - if msg.Message.Role != "assistant" { - return translation{}, nil - } - - t.mergeUsage(msg.Message.Usage, msg.Message.Provider, msg.Message.Model) - if text := extractText(msg.Message.Content); text != "" { - t.finalText = text - } - - if msg.Message.StopReason == "error" { - t.failed = true - errMsg := msg.Message.ErrorMessage - if errMsg == "" { - errMsg = "pi: assistant message ended with error" - } - env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - } - return translation{}, nil -} - -func extractText(content []json.RawMessage) string { - var b strings.Builder - for _, raw := range content { - var item struct { - Type string `json:"type"` - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - if item.Type == "text" { - b.WriteString(item.Text) - } - } - return b.String() -} - -func (t *translator) mergeUsage(u piUsage, provider, model string) { - t.usageSet = true - t.usage.InputTokens += u.Input - t.usage.OutputTokens += u.Output - t.usage.CostUSD += u.Cost.Total - if provider != "" { - t.usage.Provider = provider - } - if model != "" { - t.usage.Model = model - } - // pi reports usage per assistant message; a tool loop yields several - // message_end frames, so accumulate cache/reasoning/total the same way as - // input/output above — overwriting would leave Raw showing only the last - // frame while the summed token counts reflect all of them. - t.addRawTokens("cache_read_tokens", u.CacheRead) - t.addRawTokens("cache_write_tokens", u.CacheWrite) - t.addRawTokens("cache_write_1h_tokens", u.CacheWrite1h) - t.addRawTokens("reasoning_tokens", u.Reasoning) - t.addRawTokens("total_tokens", u.TotalTokens) -} - -// addRawTokens sums one counter into usage.Raw. In-process the values stay -// int32 (matching piUsage); they only become float64 once the envelope is -// JSON-encoded downstream, which is why the lookup asserts int32. -func (t *translator) addRawTokens(key string, v int32) { - if v == 0 { - return - } - if t.usage.Raw == nil { - t.usage.Raw = map[string]any{} - } - if existing, ok := t.usage.Raw[key].(int32); ok { - v += existing - } - t.usage.Raw[key] = v -} - -func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - var envs []proto.Envelope - - content := strings.TrimSpace(t.deltaBuf.String()) - if content == "" && t.finalText != "" { - content = strings.TrimSpace(t.finalText) - if content != "" { - if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: content, Sequence: t.seq.Add(1)}); err == nil { - envs = append(envs, env) - } - } - } - - usage := t.usage - if usage.Provider == "" { - usage.Provider = "pi" - } - if t.usageSet { - if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { - envs = append(envs, env) - } - } - - terminalFailed := t.failed || waitErr != nil || cancelled - if waitErr != nil || cancelled { - msg := "pi: subprocess exited without success" - if waitErr != nil { - msg = fmt.Sprintf("pi: subprocess exited: %v", waitErr) - } - if strings.TrimSpace(stderr) != "" { - msg += ": " + truncate(strings.TrimSpace(stderr), 400) - } - if cancelled { - msg = "pi: cancelled" - } - if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { - envs = append(envs, env) - } - } - - metadata := map[string]any{"connector_path": "pi_print"} - if t.sessionID != "" && !terminalFailed { - metadata[proto.DoneMetaAgentSessionID] = t.sessionID - metadata[proto.DoneMetaAgentSessionType] = "pi_session" - } - if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ - Content: content, - Transcript: strings.Join(t.rawLines, "\n"), - Usage: usage, - Metadata: metadata, - }); err == nil { - envs = append(envs, env) - } - return envs -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] -} diff --git a/apps/parsar-daemon/internal/agent/pi/parser_test.go b/apps/parsar-daemon/internal/agent/pi/parser_test.go deleted file mode 100644 index 86f744164..000000000 --- a/apps/parsar-daemon/internal/agent/pi/parser_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package pi_test - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestTranslateSessionHeaderSurfacesSessionID(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if tx.SessionID != "sess-abc" { - t.Fatalf("SessionID = %q, want sess-abc", tx.SessionID) - } - if len(tx.Envelopes) != 0 { - t.Fatalf("session header should emit no envelopes, got %#v", tx.Envelopes) - } -} - -func TestTranslateTextDeltaEmitsDelta(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "hello" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } -} - -func TestTranslateThinkingDeltaEmitsThinking(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"pondering"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeThinking { - t.Fatalf("thinking envelopes = %#v", tx.Envelopes) - } - think := decodePayload[proto.ThinkingPayload](t, tx.Envelopes[0]) - if think.Text != "pondering" || think.Sequence == 0 { - t.Fatalf("thinking payload = %#v", think) - } -} - -func TestTranslateToolExecutionStartEmitsBeforeToolCall(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"tool_execution_start","toolCallId":"t1","toolName":"bash","args":{"cmd":"ls"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { - t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) - } - tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) - if tc.ID != "t1" || tc.Name != "bash" || tc.Stage != "before" { - t.Fatalf("toolcall payload = %#v", tc) - } - if tc.Args["cmd"] != "ls" { - t.Fatalf("toolcall args = %#v", tc.Args) - } -} - -func TestTranslateToolExecutionEndEmitsAfterToolCall(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"tool_execution_end","toolCallId":"t1","toolName":"bash","result":{"stdout":"x"},"isError":true}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { - t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) - } - tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) - if tc.ID != "t1" || tc.Stage != "after" { - t.Fatalf("toolcall payload = %#v", tc) - } - if tc.Result["is_error"] != true { - t.Fatalf("toolcall result = %#v", tc.Result) - } -} - -func TestTranslateMessageEndCapturesUsage(t *testing.T) { - tr := pi.NewTranslatorForTest("run-u") - line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"provider":"anthropic","model":"claude-x","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"input":0.1,"output":0.2,"cacheRead":0,"cacheWrite":0,"total":0.3}},"stopReason":"stop"}}` - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.Provider != "anthropic" || got.Model != "claude-x" { - t.Fatalf("usage provider/model = %#v", got) - } - if got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.3 { - t.Fatalf("usage tokens/cost = %#v", got) - } - if got.Raw["cache_read_tokens"] != float64(2) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -// A tool loop makes pi emit one message_end per assistant turn. Every -// counter — including the cache/reasoning/total ones parked in Raw — must -// sum across frames, not get clobbered by the final frame. -func TestTranslateMessageEndAccumulatesUsageAcrossFrames(t *testing.T) { - tr := pi.NewTranslatorForTest("run-multi") - first := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"a"}],"provider":"anthropic","model":"m","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"total":0.3}},"stopReason":"tool_use"}}` - second := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"provider":"anthropic","model":"m","usage":{"input":5,"output":4,"cacheRead":6,"cacheWrite":2,"reasoning":1,"totalTokens":12,"cost":{"total":0.2}},"stopReason":"stop"}}` - for _, line := range []string{first, second} { - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.InputTokens != 15 || got.OutputTokens != 11 { - t.Fatalf("summed input/output = %d/%d, want 15/11", got.InputTokens, got.OutputTokens) - } - if got.CostUSD < 0.49 || got.CostUSD > 0.51 { - t.Fatalf("summed cost = %v, want ~0.5", got.CostUSD) - } - // Raw round-trips through JSON, so the counters decode back as float64. - for key, want := range map[string]float64{ - "cache_read_tokens": 8, - "cache_write_tokens": 3, - "reasoning_tokens": 4, - "total_tokens": 35, - } { - if got.Raw[key] != want { - t.Fatalf("Raw[%q] = %#v, want %v (must sum across frames)", key, got.Raw[key], want) - } - } -} - -// pi exits 0 even when the model errors: it emits a message_end whose -// assistant message carries stopReason "error". The parser MUST surface -// that as TypeError despite the clean process exit. -func TestTranslateMessageEndErrorStopReasonEmitsError(t *testing.T) { - tr := pi.NewTranslatorForTest("run-err") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-bad","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - line := `{"type":"message_end","message":{"role":"assistant","content":[],"provider":"anthropic","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"boom"}}` - tx, err := tr.Translate([]byte(line)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - var found bool - for _, env := range tx.Envelopes { - if env.Type == proto.TypeError { - ep := decodePayload[proto.ErrorPayload](t, env) - if strings.Contains(ep.Error, "boom") { - found = true - } - } - } - if !found { - t.Fatalf("expected TypeError mentioning boom, got %#v", tx.Envelopes) - } - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("failed pi turn must not persist session metadata: %#v", done.Metadata) - } -} - -func TestTerminalAlwaysEmitsDoneWithSessionMetadata(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - if _, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)); err != nil { - t.Fatalf("Translate delta: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - last := envs[len(envs)-1] - if last.Type != proto.TypeDone { - t.Fatalf("last env = %q, want done", last.Type) - } - done := decodePayload[proto.DonePayload](t, last) - if done.Content != "hello" { - t.Fatalf("done content = %q, want hello", done.Content) - } - if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-abc" { - t.Fatalf("done metadata = %#v, want agent_session_id sess-abc", done.Metadata) - } - if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { - t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) - } -} - -func TestTerminalErrorIncludesStderr(t *testing.T) { - tr := pi.NewTranslatorForTest("run-e") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-failed","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) - if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("error terminal envs = %#v", envs) - } - errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) - if !strings.Contains(errPayload.Error, "bad auth") { - t.Fatalf("error payload = %#v", errPayload) - } - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("failed pi process must not persist session metadata: %#v", done.Metadata) - } -} - -func TestMessageEndContentFallbackWhenNoDeltas(t *testing.T) { - tr := pi.NewTranslatorForTest("run-f") - line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"final answer"}],"provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop"}}` - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "final answer" { - t.Fatalf("done content = %q, want final answer", done.Content) - } -} - -func decodePayload[T any](t *testing.T, env proto.Envelope) T { - t.Helper() - var out T - if err := json.Unmarshal(env.Payload, &out); err != nil { - t.Fatalf("decode %s payload: %v", env.Type, err) - } - return out -} diff --git a/apps/parsar-daemon/internal/agent/pi/session.go b/apps/parsar-daemon/internal/agent/pi/session.go deleted file mode 100644 index 89661d33b..000000000 --- a/apps/parsar-daemon/internal/agent/pi/session.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package pi is the agent_kind="pi" adapter. It drives the pi CLI via -// `pi --mode json -p `, translating pi's NDJSON event stream on -// stdout into proto.Envelope frames for the dispatch router. -package pi - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -type sessionConfig struct { - piBinary string - extraArgs []string - killTimeout time.Duration - logger *slog.Logger -} - -func defaultConfig() sessionConfig { - return sessionConfig{piBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} -} - -// Factory implements agent.Factory for agent_kind="pi". -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `pi --mode json` subprocess. -type Session struct { - runID string - cfg sessionConfig - - proc *clirunner.Process - out chan<- proto.Envelope - - cancelCtx context.Context - - cancelOnce sync.Once - closeOutOnce sync.Once - cleanup func() - - stderrMu sync.Mutex - stderr bytes.Buffer -} - -var _ agent.Session = (*Session)(nil) - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("pi: nil out channel") - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.piBinary == "" { - cfg.piBinary = defaultBinary() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - // pi needs an explicit --skill flag per skill (unlike Claude Code's - // auto-scan), so installSkills returns dirs even on a cache hit. - opts := req.AgentOptions - if rawSkills, ok := opts["skills"]; ok { - descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) - for _, w := range decodeWarns { - cfg.logger.Warn("pi: skill descriptor decode warning", "run_id", req.RunID, "msg", w) - } - root, rErr := resolveSkillsRoot(req.ConversationID, req.RunID) - if rErr != nil { - return nil, fmt.Errorf("pi: resolve skills root: %w", rErr) - } - installRes, iErr := installSkills(parent, cfg.logger, root, descriptors) - if iErr != nil { - return nil, fmt.Errorf("pi: install skills: %w", iErr) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("pi: skill install warning", "run_id", req.RunID, "msg", w) - } - if len(installRes.SkillDirs) > 0 { - opts = cloneAgentOptions(req.AgentOptions) - opts["skill_dirs"] = mergeSkillDirs(opts["skill_dirs"], installRes.SkillDirs) - } - } - - // Materialise pi's managed config and pin --session-dir to the stable - // conversation/agent/engine state key so --session can resolve reliably. - provOpts, provErr := applyPiRuntimeState(opts, req.AgentStateKey, req.ConversationID, req.RunID) - if provErr != nil { - return nil, fmt.Errorf("pi: apply managed provider: %w", provErr) - } - opts = provOpts - - prompt, err := req.Input.TextOnly() - if err != nil { - return nil, err - } - buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts, req.AgentSessionID) - if err != nil { - return nil, fmt.Errorf("pi: build args: %w", err) - } - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - dir := "" - if buildRes.WorkDir != "" { - dir = buildRes.WorkDir - } - proc, err := clirunner.Start(clirunner.StartOptions{ - Parent: parent, - Binary: cfg.piBinary, - Args: args, - Dir: dir, - Env: append(os.Environ(), buildRes.Env...), - KillTimeout: cfg.killTimeout, - }) - if err != nil { - buildRes.Cleanup() - return nil, fmt.Errorf("pi: start %q: %w", cfg.piBinary, err) - } - - s := &Session{ - runID: req.RunID, - cfg: cfg, - proc: proc, - out: out, - cancelCtx: proc.Context(), - cleanup: buildRes.Cleanup, - } - go s.pumpStderr(proc.Stderr) - go s.run(proc.Stdout) - return s, nil -} - -func (s *Session) Cancel(context.Context) error { - s.cancelOnce.Do(func() { - s.proc.Cancel() - }) - return nil -} - -func (s *Session) run(stdout io.Reader) { - defer s.cleanup() - defer s.closeOut() - - tr := newTranslator(s.runID) - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for sc.Scan() { - tx, err := tr.Translate(sc.Bytes()) - if err != nil { - s.cfg.logger.Warn("pi: translate line", "run_id", s.runID, "err", err) - continue - } - for _, env := range tx.Envelopes { - select { - case s.out <- env: - case <-s.cancelCtx.Done(): - _ = s.proc.Wait() - return - } - } - } - if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { - s.cfg.logger.Warn("pi: scan stdout", "run_id", s.runID, "err", err) - } - - waitErr := s.proc.Wait() - for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { - s.trySend(env) - } -} - -func (s *Session) pumpStderr(stderr io.Reader) { - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - for sc.Scan() { - line := sc.Text() - s.stderrMu.Lock() - if s.stderr.Len() > 0 { - s.stderr.WriteByte('\n') - } - s.stderr.WriteString(line) - s.stderrMu.Unlock() - s.cfg.logger.Warn("pi stderr", "run_id", s.runID, "line", line) - } -} - -func (s *Session) stderrString() string { - s.stderrMu.Lock() - defer s.stderrMu.Unlock() - return s.stderr.String() -} - -func (s *Session) trySend(env proto.Envelope) { - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("pi: terminal send timed out", "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/pi/session_test.go b/apps/parsar-daemon/internal/agent/pi/session_test.go deleted file mode 100644 index 60b83f7cf..000000000 --- a/apps/parsar-daemon/internal/agent/pi/session_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package pi_test - -import ( - "context" - "encoding/json" - "os" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `pi` when -// PI_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS -// line doesn't pollute fake stdout. -const piHelperEnvKey = "PI_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(piHelperEnvKey); role != "" { - runFakePi(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -func runFakePi(role string) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - - // Record argv so the skill-injection test can assert --skill - // reached the subprocess. - if argsFile := os.Getenv("PI_TESTHELPER_ARGS_FILE"); argsFile != "" { - _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o644) - } - - sawModeJSON := false - for i, arg := range os.Args { - if arg == "--mode" && i+1 < len(os.Args) && os.Args[i+1] == "json" { - sawModeJSON = true - } - } - - header := map[string]any{"type": "session", "id": "sess-xyz", "cwd": "/", "timestamp": "t"} - delta := func(s string) map[string]any { - return map[string]any{ - "type": "message_update", - "message": map[string]any{"role": "assistant"}, - "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": s}, - } - } - - switch role { - case "json-success": - if !sawModeJSON { - _, _ = os.Stderr.WriteString("missing --mode json\n") - os.Exit(64) - } - _ = enc.Encode(header) - _ = enc.Encode(delta("hi ")) - _ = enc.Encode(delta("there")) - _ = enc.Encode(map[string]any{ - "type": "message_end", - "message": map[string]any{ - "role": "assistant", - "content": []any{map[string]any{"type": "text", "text": "hi there"}}, - "provider": "anthropic", - "model": "claude-x", - "stopReason": "stop", - "usage": map[string]any{ - "input": 4, "output": 2, "cacheRead": 0, "cacheWrite": 0, - "totalTokens": 6, - "cost": map[string]any{"input": 0.1, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.12}, - }, - }, - }) - - case "error-stop": - // pi exits 0 even when the model errors — it just emits a - // message_end with stopReason "error". - _ = enc.Encode(header) - _ = enc.Encode(map[string]any{ - "type": "message_end", - "message": map[string]any{ - "role": "assistant", - "content": []any{}, - "provider": "anthropic", - "model": "claude-x", - "stopReason": "error", - "errorMessage": "model boom", - "usage": map[string]any{ - "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0, - "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}, - }, - }, - }) - - case "nonzero": - _, _ = os.Stderr.WriteString("bad auth from fake pi\n") - os.Exit(17) - - case "hang": - _ = enc.Encode(header) - _ = enc.Encode(delta("started")) - time.Sleep(10 * time.Minute) - } -} - -func piHelperConfig() pi.SessionConfigForTest { - return pi.SessionConfigForTest{ - PiBinary: os.Args[0], - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -func piHelperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - piHelperEnvKey: role, - }, - }, - } -} - -func drainPi(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_json", "hello", "json-success"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeDelta) - mustContainPi(t, types, proto.TypeUsage) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - for _, env := range got { - if env.ID != "run_json" { - t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) - } - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "hi there" { - t.Fatalf("done content = %q, want hi there", done.Content) - } - if done.Usage.Provider != "anthropic" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { - t.Fatalf("done usage = %#v", done.Usage) - } - if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-xyz" { - t.Fatalf("done metadata = %#v, want agent_session_id sess-xyz", done.Metadata) - } - if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { - t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) - } -} - -// pi exits 0 on a model error: the session must still surface TypeError -// (from stopReason) and Done, and close out. -func TestSessionModelErrorStopReasonStillEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_mod_err", "hello", "error-stop"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeError) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "model boom") { - t.Fatalf("error payload = %#v, want model boom", errPayload) - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("model error must not persist session metadata: %#v", done.Metadata) - } -} - -func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_err", "hello", "nonzero"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeError) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "bad auth from fake pi") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_cancel", "hello", "hang"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } -} - -func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { - var session any = (*pi.Session)(nil) - if _, ok := session.(agent.PermissionResponder); ok { - t.Fatal("unexpected permission responder") - } - if _, ok := session.(agent.UserChoiceResponder); ok { - t.Fatal("unexpected user-choice responder") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_nil", "hello", "json-success"), nil, piHelperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - out := make(chan proto.Envelope, 4) - _, err := pi.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, piHelperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := piHelperConfig() - cfg.PiBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_bad", "hello", "json-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func piEnvTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, env := range envs { - out[i] = env.Type - } - return out -} - -func mustContainPi(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Fatalf("expected %q in %v", needle, haystack) - } -} diff --git a/apps/parsar-daemon/internal/agent/pi/version.go b/apps/parsar-daemon/internal/agent/pi/version.go deleted file mode 100644 index f955484c6..000000000 --- a/apps/parsar-daemon/internal/agent/pi/version.go +++ /dev/null @@ -1,36 +0,0 @@ -package pi - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" -) - -// InstallURL points operators at the pi documentation when the daemon -// can see the adapter but not the CLI binary. -const InstallURL = "https://github.com/earendil-works/pi" - -// defaultBinary is the executable to probe and spawn: binpath.Pi() -// honours the OAC_RUNTIME_PI_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.Pi() } - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot -// be located on PATH. Callers use errors.Is to distinguish an install -// problem from a present-but-broken CLI. -var ErrCLINotFound = errors.New("pi CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "pi", - DefaultBinary: defaultBinary(), - MissingError: ErrCLINotFound, - TrimBinary: true, - StderrFallback: true, - }) -} diff --git a/apps/parsar-daemon/internal/agent/pi/version_test.go b/apps/parsar-daemon/internal/agent/pi/version_test.go deleted file mode 100644 index 629ddffbc..000000000 --- a/apps/parsar-daemon/internal/agent/pi/version_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package pi_test - -import ( - "context" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "pi", - DefaultBinary: "pi", - MissingError: pi.ErrCLINotFound, - Check: pi.CheckCLIAvailable, - WhitespaceDefaults: true, - }) -} - -func TestCheckCLIAvailableFallsBackToStderr(t *testing.T) { - stub := testutil.WriteStub(t, "stderr-pi", "#!/bin/sh\nprintf ' pi 0.74.2\\nextra line \\n' 1>&2\n") - version, err := pi.CheckCLIAvailable(context.Background(), stub) - if err != nil { - t.Fatalf("check CLI: %v", err) - } - if version != "pi 0.74.2" { - t.Fatalf("version = %q, want %q", version, "pi 0.74.2") - } -} diff --git a/apps/parsar-daemon/internal/agent/registry.go b/apps/parsar-daemon/internal/agent/registry.go deleted file mode 100644 index 0185e0c2e..000000000 --- a/apps/parsar-daemon/internal/agent/registry.go +++ /dev/null @@ -1,121 +0,0 @@ -package agent - -import ( - "errors" - "fmt" - "slices" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" -) - -// ErrUnknownPermission is returned by PermissionResponder.SubmitPermission when -// the permID doesn't match any outstanding request. The router uses -// this to distinguish a benign race from a real forwarding failure. -var ErrUnknownPermission = errors.New("agent: unknown permission id") - -// ErrUnknownAsk is returned by UserChoiceResponder.SubmitPromptForUserChoice when -// the askID doesn't match any outstanding ask. Same race semantics as -// ErrUnknownPermission. -var ErrUnknownAsk = errors.New("agent: unknown ask id") - -var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") - -// Registry maps agent_kind → Factory and keeps the daemon-advertised -// capability descriptor for each kind. Safe for concurrent use. -type Registry struct { - mu sync.RWMutex - factories map[string]Factory - preparers map[string]PreparationFactory - executors map[string]ExecutorFactory - kinds map[string]proto.SupportedAgentKind - configurations map[string]harnessconfig.Configuration -} - -func NewRegistry() *Registry { - return &Registry{ - factories: make(map[string]Factory), - preparers: make(map[string]PreparationFactory), - executors: make(map[string]ExecutorFactory), - kinds: make(map[string]proto.SupportedAgentKind), - configurations: make(map[string]harnessconfig.Configuration), - } -} - -// Resolve returns the factory for kind, or wraps ErrUnsupportedKind. -func (r *Registry) Resolve(kind string) (Factory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f, ok := r.factories[kind] - if !ok { - return nil, fmt.Errorf("%w: %q", ErrUnsupportedKind, kind) - } - return f, nil -} - -// Kinds returns the list of registered kinds sorted lexicographically. -func (r *Registry) Kinds() []string { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]string, 0, len(r.factories)) - for k := range r.factories { - out = append(out, k) - } - slices.Sort(out) - return out -} - -// SupportedAgentKinds returns the daemon-advertised capability -// descriptors sorted by kind so heartbeat payloads are stable. -func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]proto.SupportedAgentKind, 0, len(r.factories)) - for kind := range r.factories { - info := r.kinds[kind] - out = append(out, info) - } - slices.SortFunc(out, func(a, b proto.SupportedAgentKind) int { - if a.Kind < b.Kind { - return -1 - } - if a.Kind > b.Kind { - return 1 - } - return 0 - }) - return out -} - -func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - factory := r.executors[kind] - if factory == nil { - return nil, fmt.Errorf("agent: executor unavailable for %q", kind) - } - return factory, nil -} - -func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f := r.preparers[kind] - if f == nil { - return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) - } - return f, nil -} - -// Configuration returns an owned declaration for registry wrappers. Wrappers -// transfer it with the factory; they must not infer configuration from kind names. -func (r *Registry) Configuration(kind string) (harnessconfig.Configuration, error) { - r.mu.RLock() - defer r.mu.RUnlock() - configuration, ok := r.configurations[kind] - if !ok { - return harnessconfig.Configuration{}, ErrUnsupportedKind - } - return configuration.Clone(), nil -} diff --git a/apps/parsar-daemon/internal/agent/registry_test.go b/apps/parsar-daemon/internal/agent/registry_test.go deleted file mode 100644 index 82068c2ee..000000000 --- a/apps/parsar-daemon/internal/agent/registry_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package agent_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "reflect" - "slices" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -func stubFactory(marker string) agent.Factory { - return func(_ context.Context, _ proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - return stubSession{marker: marker}, nil - } -} - -type stubSession struct{ marker string } - -func (stubSession) Cancel(context.Context) error { return nil } -func (stubSession) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { - return nil -} -func (stubSession) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { - return nil -} - -func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - - f, err := reg.Resolve("claude_code") - if err != nil { - t.Fatalf("Resolve: %v", err) - } - sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "claude_code"}, nil) - if err != nil { - t.Fatalf("factory: %v", err) - } - stub, ok := sess.(stubSession) - if !ok || stub.marker != "cc" { - t.Errorf("resolved factory returned %#v, want stubSession{marker:\"cc\"}", sess) - } -} - -func TestRegistryResolveUnknownKindReturnsTypedError(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - - _, err := reg.Resolve("opencode") - if !errors.Is(err, agent.ErrUnsupportedKind) { - t.Errorf("Resolve unknown = %v, want ErrUnsupportedKind chain", err) - } -} - -func TestRegistryRegisterOverwrites(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v1")) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("v2")) - - f, err := reg.Resolve("k") - if err != nil { - t.Fatalf("Resolve: %v", err) - } - sess, _ := f(context.Background(), proto.PromptRequestPayload{}, nil) - if got := sess.(stubSession).marker; got != "v2" { - t.Errorf("overwrite: marker = %q, want v2", got) - } -} - -func TestRegistryKindsReportsRegistered(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "opencode", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("oc")) - - got := reg.Kinds() - slices.Sort(got) - want := []string{"claude_code", "opencode"} - if !slices.Equal(got, want) { - t.Errorf("Kinds = %v, want %v", got, want) - } -} - -func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("Register(\"\", ...) did not panic") - } - }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("x")) -} - -func TestRegistryRegisterPanicsOnNilFactory(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("Register(kind, nil) did not panic") - } - }() - agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, nil) -} - -func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{ - Kind: "opencode", - Available: false, - Version: "missing", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - }), - }, harnessconfig.Configuration{}, stubFactory("oc")) - reg.RegisterKind(proto.SupportedAgentKind{ - Kind: "claude_code", - Available: true, - Version: "1.2.3", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - }), - }, harnessconfig.Configuration{}, stubFactory("cc")) - - got := reg.SupportedAgentKinds() - if len(got) != 2 { - t.Fatalf("SupportedAgentKinds len = %d, want 2: %#v", len(got), got) - } - if got[0].Kind != "claude_code" || got[1].Kind != "opencode" { - t.Fatalf("SupportedAgentKinds sort = %#v, want claude_code then opencode", got) - } - if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Permissions.IsSupported() || !got[0].Capabilities.Resume.IsSupported() { - t.Fatalf("claude_code descriptor not preserved: %#v", got[0]) - } - if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.Streaming.IsSupported() { - t.Fatalf("opencode descriptor not preserved: %#v", got[1]) - } -} - -func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { - registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("native")) - if _, err := registry.ResolveExecutor("native"); err == nil { - t.Fatal("legacy factory implied reusable execution") - } - expected := errors.New("executor factory") - registry.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, expected }) - factory, err := registry.ResolveExecutor("native") - if err != nil { - t.Fatal(err) - } - if _, err := factory(t.Context(), proto.PromptRequestPayload{}); !errors.Is(err, expected) { - t.Fatal(err) - } - registry.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("replacement")) - if _, err := registry.ResolveExecutor("native"); err == nil { - t.Fatal("replacing a kind retained its old executor capability") - } -} - -func TestRegistryRejectsEveryOmittedCapabilityBeforeReplacement(t *testing.T) { - valid := prototest.Capabilities(proto.AgentKindCapabilities{}) - for i := 0; i < reflect.TypeOf(valid).NumField(); i++ { - t.Run(reflect.TypeOf(valid).Field(i).Name, func(t *testing.T) { - registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: true, Capabilities: valid}, harnessconfig.Configuration{}, stubFactory("original")) - missing := valid - reflect.ValueOf(&missing).Elem().Field(i).Set(reflect.ValueOf(proto.CapabilityUnspecified)) - func() { - defer func() { - if recover() == nil { - t.Error("incomplete declaration registered") - } - }() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture", Available: false, Capabilities: missing}, harnessconfig.Configuration{}, stubFactory("replacement")) - }() - factory, err := registry.Resolve("fixture") - if err != nil { - t.Fatal(err) - } - session, err := factory(t.Context(), proto.PromptRequestPayload{}, nil) - if err != nil || session.(stubSession).marker != "original" { - t.Fatal("failed declaration changed registry") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go deleted file mode 100644 index e2f13bd22..000000000 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli - -import ( - "context" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func discoverMCode(parent context.Context, rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { - if check == nil { - check = mcode.CheckCLIAvailable - } - result := proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilityUnsupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }} - ctx, cancel := context.WithTimeout(parent, cliVersionTimeout) - defer cancel() - version, err := check(ctx, "") - if err != nil { - fmt.Fprintf(rc.stderr, "oac-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) - return result - } - result.Available, result.Version = true, version - if mcode.SupportsExecution(version) { - result.Capabilities.Steering = proto.CapabilitySupported - result.Capabilities.DurableTurns = proto.CapabilitySupported - result.Capabilities.DurableInputReceipts = proto.CapabilitySupported - result.Capabilities.ExecutionControls = proto.CapabilitySupported - result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported - result.Capabilities.ToolObservations = proto.CapabilitySupported - result.Capabilities.SubagentControl = proto.CapabilitySupported - // Native preparation verifies the applied admission/tool profile before input. - result.Capabilities.SubagentObservations = proto.CapabilitySupported - result.Capabilities.EnvironmentNone = proto.CapabilitySupported - result.Capabilities.MCPHTTPTools = proto.CapabilitySupported - result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported - } - fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) - return result -} diff --git a/apps/parsar-daemon/internal/cli/mcp_test.go b/apps/parsar-daemon/internal/cli/mcp_test.go deleted file mode 100644 index 7c756cba6..000000000 --- a/apps/parsar-daemon/internal/cli/mcp_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import ( - "context" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" -) - -func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { - t.Setenv(claudeSDKEntrypointEnv, "") - checks := unavailableCLIChecks() - checks.Codex = func(context.Context, string) (string, error) { return "codex 0.153.4", nil } - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) - if err != nil { - t.Fatal(err) - } - registry := agent.NewRegistry() - registerAgentKinds(registry, discovery, "https://service.example") - for _, kind := range registry.SupportedAgentKinds() { - if kind.Capabilities.MCPHTTPBearerAuth.IsSupported() != (kind.Kind == "codex") { - t.Fatal("bearer capability missing or advertised for another adapter") - } - if kind.Kind == "codex" && (!kind.Available || !kind.Capabilities.MCPHTTPTools.IsSupported() || !kind.Capabilities.EnvironmentNone.IsSupported()) { - t.Fatal("bearer capability lacks prerequisite profile") - } - } -} - -func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { - for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { - checks := unavailableCLIChecks() - checks.Codex = func(context.Context, string) (string, error) { return version, nil } - got, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) - if err != nil { - t.Fatal(err) - } - if got.Codex.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { - t.Fatal("unverified native combination advertised") - } - if got.Codex.Capabilities.NativeSessionRecovery.IsSupported() != (version == "codex-cli 0.153.4") { - t.Fatal("unverified native recovery advertised") - } - if got.ClaudeCode.Capabilities.MCPHTTPRequired.IsSupported() || got.OpenCode.Capabilities.MCPHTTPRequired.IsSupported() || got.Pi.Capabilities.MCPHTTPRequired.IsSupported() { - t.Fatal("other engine advertised combination") - } - } -} diff --git a/apps/parsar-daemon/internal/cli/preparation_test.go b/apps/parsar-daemon/internal/cli/preparation_test.go deleted file mode 100644 index bac218f4c..000000000 --- a/apps/parsar-daemon/internal/cli/preparation_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package cli - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/authoring" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { - for _, supported := range []bool{false, true} { - reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, OpenCode: proto.SupportedAgentKind{Kind: "opencode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, Pi: proto.SupportedAgentKind{Kind: "pi", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, MCode: proto.SupportedAgentKind{Kind: "mcode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}}, "http://unreachable.invalid") - _, err := reg.ResolvePreparation("codex") - if (err == nil) != supported { - t.Fatal("unverified native version advertised preparation") - } - if !supported { - continue - } - stop := errors.New("controlled preparation stop") - reg.RegisterPreparation("codex", true, func(_ context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if req.RunID != "" || req.WorkspaceAuthoring || len(req.AgentOptions) != 0 { - t.Error("product wrapper injected preparation context") - } - return nil, stop - }) - wrapped := authoringRegistry(reg, authoring.New(nil)) - prepare, err := wrapped.ResolvePreparation("codex") - if err != nil { - t.Fatal(err) - } - if _, err := prepare(t.Context(), proto.PromptRequestPayload{AgentKind: "codex"}); !errors.Is(err, stop) { - t.Fatal("raw preparation was lost or wrapped", err) - } - for _, info := range wrapped.SupportedAgentKinds() { - if info.Kind == "codex" && (!info.Capabilities.Preparation.IsSupported() || !info.Capabilities.WorkspaceReadPreparation.IsSupported()) { - t.Fatal("real heartbeat registry lost preparation") - } - } - wrapped.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, stop - }) - if _, err := wrapped.ResolvePreparation("codex"); err == nil { - t.Fatal("factory replacement retained stale preparation") - } - } -} diff --git a/apps/parsar-daemon/internal/dispatch/cancellation.go b/apps/parsar-daemon/internal/dispatch/cancellation.go deleted file mode 100644 index eeedf9e9f..000000000 --- a/apps/parsar-daemon/internal/dispatch/cancellation.go +++ /dev/null @@ -1,81 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type cancellationOutcomeProvider interface { - CancellationOutcome() proto.DonePayload -} - -func (r *Router) releaseCompletedSession(state *sessionState) error { - r.mu.Lock() - state.retain = false - r.mu.Unlock() - receiptErr := r.finishSteering(state) - err := state.session.Cancel(context.Background()) - state.ctxCancel() - return errors.Join(receiptErr, err) -} - -func (r *Router) handlePromptCancel(ctx context.Context, env proto.Envelope) error { - var request proto.PromptCancelPayload - if err := env.DecodePayload(&request); err != nil { - return err - } - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return ErrRouterClosed - } - state := r.sessions[env.ID] - if state != nil { - state.retain = false - } - var cancelSession func(context.Context) error - if state != nil && state.preparedHandoff != nil { - handoff := state.preparedHandoff - release, attempt := r.claimPreparedReleaseLocked(state, true, "", true) - if request.DeliveryID != "" { - r.shutdownWG.Add(1) - go r.sendPreparedCancellation(state, handoff, release, attempt, env, request.DeliveryID) - } - r.mu.Unlock() - return nil - } - if state != nil && state.session != nil { - cancelSession = state.session.Cancel - } - r.mu.Unlock() - ack := proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, ErrorCode: "run_inactive"} - if state != nil && cancelSession == nil { - ack.ErrorCode = "not_ready" - } else if cancelSession != nil { - if err := cancelSession(ctx); err != nil { - r.log.WarnContext(ctx, "session.Cancel failed", "run_id", env.ID, "err", err) - ack.ErrorCode = "cancel_failed" - } else { - ack.Applied, ack.ErrorCode = true, "" - if provider, ok := state.session.(cancellationOutcomeProvider); ok { - outcome := provider.CancellationOutcome() - ack.Outcome = &outcome - } - } - state.ctxCancel() - } - return r.sendCancellationAck(ctx, env, ack) -} - -func (r *Router) sendCancellationAck(ctx context.Context, env proto.Envelope, ack proto.InteractionDecisionAckPayload) error { - if ack.DeliveryID == "" { - return nil - } - reply, err := proto.NewEnvelopeWithTrace(proto.TypeInteractionDecisionAck, env.ID, ack, env.Trace) - if err != nil { - return err - } - return r.sender.Send(ctx, reply) -} diff --git a/apps/parsar-daemon/internal/dispatch/cancellation_test.go b/apps/parsar-daemon/internal/dispatch/cancellation_test.go deleted file mode 100644 index b49586ad8..000000000 --- a/apps/parsar-daemon/internal/dispatch/cancellation_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type cancelReceiptSession struct { - *fakeSession - entered chan struct{} - release chan struct{} - err error -} - -func (s *cancelReceiptSession) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} -} - -func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "release", proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "stable", ReleaseOnCompletion: true})); err != nil { - t.Fatal(err) - } - sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) - <-sess.entered - if len(h.sender.snapshot()) != 0 { - t.Fatal("completion acknowledged before native writer was released") - } - close(sess.release) - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "release completion") - frames := h.sender.snapshot() - if len(frames) != 1 || frames[0].Type != proto.TypeDone || sess.cancels() != 1 { - t.Fatal("completion or native release missing") - } -} - -func (s *cancelReceiptSession) Cancel(ctx context.Context) error { - if s.entered != nil { - close(s.entered) - <-s.release - s.entered = nil - } - _ = s.fakeSession.Cancel(ctx) - return s.err -} - -func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { - for _, fails := range []bool{false, true} { - t.Run(map[bool]string{false: "applied", true: "rejected"}[fails], func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} - if fails { - sess.err = errors.New("adapter could not cancel") - } - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - sess.fakeSession = &fakeSession{out: out, closeOutOnCancel: true} - return sess, nil - }) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { - done <- h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-1"})) - }() - <-sess.entered - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - t.Fatal("cancellation acknowledged before adapter returned") - } - } - close(sess.release) - if err := <-done; err != nil { - t.Fatal(err) - } - found := false - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - found = true - var ack proto.InteractionDecisionAckPayload - _ = env.DecodePayload(&ack) - if ack.Applied == fails || ack.DeliveryID != "cancel-1" { - t.Fatalf("wrong receipt: %+v", ack) - } - if !fails && (ack.Outcome == nil || ack.Outcome.Metadata[proto.DoneMetaAgentSessionID] != "native-cancelled") { - t.Fatal("cancellation receipt lost native identity") - } - } - } - if !found { - t.Fatal("missing cancellation receipt") - } - }) - } -} - -func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { - t.Fatal(err) - } - sess := <-h.gotSess - sess.closeOutOnCancel = true - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptCancel, "legacy", proto.PromptCancelPayload{})); err != nil { - t.Fatal(err) - } - for _, env := range h.sender.snapshot() { - if env.Type == proto.TypeInteractionDecisionAck { - t.Fatal("legacy cancellation emitted new receipt") - } - } -} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities.go b/apps/parsar-daemon/internal/dispatch/capabilities.go deleted file mode 100644 index f99185ef8..000000000 --- a/apps/parsar-daemon/internal/dispatch/capabilities.go +++ /dev/null @@ -1,12 +0,0 @@ -package dispatch - -import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - -func (r *Router) availableCapabilities(kind string) (proto.AgentKindCapabilities, bool) { - for _, info := range r.registry.SupportedAgentKinds() { - if info.Kind == kind && info.Available { - return info.Capabilities, true - } - } - return proto.AgentKindCapabilities{}, false -} diff --git a/apps/parsar-daemon/internal/dispatch/environment.go b/apps/parsar-daemon/internal/dispatch/environment.go deleted file mode 100644 index 7b826397d..000000000 --- a/apps/parsar-daemon/internal/dispatch/environment.go +++ /dev/null @@ -1,23 +0,0 @@ -package dispatch - -import ( - "errors" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { - if err := req.ValidateProgrammaticToolCallingDisable(caps.ProgrammaticToolCallingDisable.IsSupported()); err != nil { - return err - } - if err := req.ValidateToolSearch(caps.ToolSearch.IsSupported()); err != nil { - return err - } - if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment.IsSupported()) { - return errors.New("engine does not support this local Environment configuration") - } - if req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported() { - return errors.New("engine does not support execution environment none") - } - return validateMCPHTTP(req, caps) -} diff --git a/apps/parsar-daemon/internal/dispatch/environment_test.go b/apps/parsar-daemon/internal/dispatch/environment_test.go deleted file mode 100644 index f092405c5..000000000 --- a/apps/parsar-daemon/internal/dispatch/environment_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil - }) - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "claude_code", DisableExecutionEnvironment: true})) - if err == nil || called { - t.Fatal("unsupported engine was started", err) - } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal(frames) - } -} - -func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { - for _, available := range []bool{false, true} { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_sdk", Available: available, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, errors.New("controlled factory stop") - }) - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "sdk", proto.PromptRequestPayload{AgentKind: "claude_sdk", DisableExecutionEnvironment: true})) - if called != available { - t.Fatalf("factory called=%t, available=%t", called, available) - } - } -} - -func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { - for _, mode := range []string{"unsupported", "unavailable", "none conflict", "supported"} { - t.Run(mode, func(t *testing.T) { - h := localPreparationHarness(t) - defer h.router.Shutdown(context.Background()) - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(mode != "unsupported")})}, - harnessconfig.Configuration{}, func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true - if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { - t.Error("local descriptor lost before factory") - } - return nil, errors.New("controlled factory stop") - }) - req := preparationRequest().Configuration - req.AgentKind = "codex" - req.DisableExecutionEnvironment = mode == "none conflict" - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "local", req)) - if called != (mode == "supported") { - t.Fatalf("unexpected factory call for %s", mode) - } - frames := h.sender.snapshot() - if len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("missing terminal error frames") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/dispatch/executor.go b/apps/parsar-daemon/internal/dispatch/executor.go deleted file mode 100644 index 9846eae02..000000000 --- a/apps/parsar-daemon/internal/dispatch/executor.go +++ /dev/null @@ -1,380 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -const executorIdleCapacity = 16 - -type executorState struct { - capabilities proto.AgentKindCapabilities - id, sessionID, environmentID, stateKey string - fingerprint [32]byte - native agent.Executor - nativeID string - ctx context.Context - cancel context.CancelFunc - admission *preparationState - run *sessionState - preparing bool - invalid bool - closeDone chan struct{} - closeReason string - closeErr error - timer *time.Timer - idleLease uint64 -} - -func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { - req.RunID, req.Input = "", nil - req.AgentSessionID = "" - req.RequireExistingNativeSession = false - req.ReleaseOnCompletion = false - data, err := json.Marshal(req) - return sha256.Sum256(data), err -} - -func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error { - req := input.Configuration - if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || req.AgentStateKey != "agents-api-"+input.SessionID || !req.StrictResume { - return r.rejectPreparation(env, "invalid_configuration") - } - caps, available := r.availableCapabilities(req.AgentKind) - if !available { - return r.rejectPreparation(env, "resource_unavailable") - } - factory, err := r.registry.ResolveExecutor(req.AgentKind) - if err != nil || !caps.Preparation.IsSupported() { - return r.rejectPreparation(env, "unsupported_preparation") - } - req, err = r.localWorkspace.Configure(req) - if err != nil { - return r.rejectPreparation(env, "invalid_configuration") - } - if validateExecutionEnvironment(req, caps) != nil || len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported() { - return r.rejectPreparation(env, "unsupported_configuration") - } - fingerprint, err := executorFingerprint(req) - if err != nil { - return r.rejectPreparation(env, "invalid_configuration") - } - encoded, err := json.Marshal(input) - if err != nil { - return r.rejectPreparation(env, "invalid_configuration") - } - requestFingerprint := sha256.Sum256(encoded) - r.mu.Lock() - if r.closed || r.suspension != nil { - r.mu.Unlock() - return ErrRouterClosed - } - if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { - r.mu.Unlock() - return r.rejectPreparation(env, "resource_unavailable") - } - r.prunePreparationsLocked() - if old := r.preparationRequests[env.ID]; old != nil { - matches, status := old.fingerprint == requestFingerprint, old.status - r.mu.Unlock() - if !matches { - return r.rejectPreparation(env, "request_conflict") - } - r.publishPreparation(old, status) - return nil - } - if len(r.preparations) >= preparationRecords { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_capacity") - } - active := 0 - for _, candidate := range r.executors { - if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { - r.mu.Unlock() - return r.rejectPreparation(env, "session_binding_conflict") - } - } - for _, owner := range r.executors { - if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { - active++ - } - } - if active >= preparationCapacity { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_capacity") - } - owner := r.executors[input.SessionID] - reused := owner != nil - if owner != nil { - code := "" - switch { - case owner.environmentID != req.EnvironmentID() || owner.fingerprint != fingerprint: - code = "configuration_conflict" - case owner.invalid: - code = "executor_cleanup_unconfirmed" - case owner.preparing || owner.admission != nil || owner.run != nil: - code = "resource_unavailable" - case req.AgentSessionID != owner.nativeID: - code = "native_session_conflict" - case req.RequireExistingNativeSession && owner.nativeID == "": - code = "history_unavailable" - } - if code != "" { - r.mu.Unlock() - return r.rejectPreparation(env, code) - } - if owner.timer != nil { - owner.timer.Stop() - } - owner.idleLease++ - } else { - if len(r.executors) >= executorIdleCapacity+preparationCapacity { - r.mu.Unlock() - return r.rejectPreparation(env, "executor_capacity") - } - ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} - r.executors[input.SessionID] = owner - r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) - } - operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: owner.capabilities, requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} - state := "preparing" - if reused { - state = "ready" - } - p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), ExecutorID: owner.id, Revision: 1, State: state, Reused: reused, ExpiresAt: p.deadline.UnixMilli()} - owner.admission = p - r.preparations[p.status.Handle], r.preparationRequests[env.ID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) - if !reused { - r.shutdownWG.Add(1) - } - status := p.status - r.mu.Unlock() - if reused { - r.publishPreparation(p, status) - } else { - go r.prepareExecutor(p, req, factory) - } - return nil -} - -func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory) { - defer r.shutdownWG.Done() - owner := p.executor - started := time.Now() - r.mu.Lock() - initial := p.status - r.mu.Unlock() - if !r.sendPreparation(p.requestID, p.trace, initial) { - r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) - } - var native agent.Executor - var err error - if owner.ctx.Err() == nil { - req, err = r.localWorkspace.Prepare(owner.ctx, req) - if err == nil && owner.ctx.Err() == nil { - native, err = factory(owner.ctx, req) - } - } else { - err = owner.ctx.Err() - } - r.mu.Lock() - owner.native, owner.preparing = native, false - r.log.Info("executor native_prepare", "executor_id", owner.id, "session_id", owner.sessionID, "duration_ms", time.Since(started).Milliseconds(), "success", err == nil && native != nil) - ready := native != nil && err == nil && !owner.invalid && !r.closed && r.suspension == nil && p.status.State == "preparing" && p.ctx.Err() == nil - p.busy = false - if ready { - p.status.State, p.status.Revision = "ready", p.status.Revision+1 - } else { - owner.invalid = true - owner.closeReason = "preparation_failed" - if p.status.State == "preparing" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 - } - p.timer.Stop() - } - status := p.status - r.mu.Unlock() - if !ready { - closeErr := r.closeExecutor(owner) - r.mu.Lock() - p.owns, p.closeErr = closeErr != nil, closeErr - if closeErr != nil { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "executor_cleanup_unconfirmed", p.status.Revision+1 - } - status = p.status - r.mu.Unlock() - } - if !r.sendPreparation(p.requestID, p.trace, status) && ready { - r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) - } -} - -func (r *Router) abandonExecutorAdmission(p *preparationState, state, code string, publish bool) { - r.mu.Lock() - owner := p.executor - if p.handoff != nil || p.status.State == "started" { - status := p.status - r.mu.Unlock() - if publish && state == "released" { - r.publishPreparation(p, status) - } - return - } - if p.status.State == "preparing" || p.status.State == "ready" { - p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 - p.timer.Stop() - p.cancel() - p.owns = false - if owner.admission == p { - owner.admission = nil - } - if owner.preparing { - owner.invalid = true - owner.cancel() - } else if !owner.invalid { - r.scheduleExecutorIdleLocked(owner) - } - } - status := p.status - r.mu.Unlock() - if publish { - r.publishPreparation(p, status) - } -} - -func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { - if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil { - return - } - if owner.timer != nil { - owner.timer.Stop() - } - idle := 0 - for _, candidate := range r.executors { - if candidate != owner && candidate.run == nil && candidate.admission == nil && !candidate.preparing { - idle++ - } - } - if idle >= executorIdleCapacity { - owner.invalid = true - owner.closeReason = "idle_capacity" - r.shutdownWG.Add(1) - go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() - return - } - owner.idleLease++ - lease := owner.idleLease - r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) - owner.timer = time.AfterFunc(r.idleTimeout, func() { - r.mu.Lock() - if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { - r.mu.Unlock() - return - } - owner.invalid = true - owner.closeReason = "idle_expired" - r.shutdownWG.Add(1) - r.mu.Unlock() - defer r.shutdownWG.Done() - _ = r.closeExecutor(owner) - }) -} - -// Close failures keep the exact owner; a later call retries only settled failures. -func (r *Router) closeExecutor(owner *executorState) error { - r.mu.Lock() - owner.invalid = true - if owner.closeReason == "" { - owner.closeReason = "invalidated" - } - if owner.timer != nil { - owner.timer.Stop() - } - if owner.preparing { - owner.cancel() - r.mu.Unlock() - return errors.New("executor preparation is still settling") - } - if done := owner.closeDone; done != nil { - select { - case <-done: - if owner.closeErr == nil { - r.mu.Unlock() - return nil - } - default: - r.mu.Unlock() - <-done - r.mu.Lock() - err := owner.closeErr - r.mu.Unlock() - return err - } - } - done := make(chan struct{}) - owner.closeDone = done - native := owner.native - r.mu.Unlock() - var err error - if native != nil { - ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) - err = native.Close(ctx) - cancel() - } - r.mu.Lock() - owner.closeErr = err - if err == nil { - owner.cancel() - } - r.log.Info("executor owner_closed", "executor_id", owner.id, "session_id", owner.sessionID, "confirmed", err == nil, "reason", owner.closeReason) - if err == nil && owner.run == nil && r.executors[owner.sessionID] == owner { - delete(r.executors, owner.sessionID) - } - close(done) - r.mu.Unlock() - return err -} - -func (r *Router) closeIdleExecutorsLocked() []*executorState { - var owners []*executorState - for _, owner := range r.executors { - owner.invalid = true - owner.closeReason = "shutdown" - if owner.preparing { - owner.cancel() - } - if owner.timer != nil { - owner.timer.Stop() - } - if owner.admission != nil && owner.run == nil { - p := owner.admission - p.cancel() - p.timer.Stop() - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 - p.owns = false - owner.admission = nil - } - if !owner.preparing && owner.run == nil { - r.shutdownWG.Add(1) - owners = append(owners, owner) - } - } - return owners -} - -func (r *Router) closeIdleExecutors(owners []*executorState) { - for _, owner := range owners { - go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() - } -} diff --git a/apps/parsar-daemon/internal/dispatch/executor_test.go b/apps/parsar-daemon/internal/dispatch/executor_test.go deleted file mode 100644 index 4453a7b50..000000000 --- a/apps/parsar-daemon/internal/dispatch/executor_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type reusableExecutor struct { - starts chan *reusableTurn - closes atomic.Int32 - mu sync.Mutex - closeErr error - startErr error -} - -func (e *reusableExecutor) Close(context.Context) error { - e.closes.Add(1) - e.mu.Lock() - defer e.mu.Unlock() - return e.closeErr -} -func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if e.startErr != nil { - return nil, e.startErr - } - t := &reusableTurn{id: id, out: out, settled: make(chan struct{})} - e.starts <- t - return t, nil -} - -type reusableTurn struct { - id string - out chan<- proto.Envelope - settled chan struct{} - once sync.Once - cancels atomic.Int32 -} - -func (t *reusableTurn) finish() { - t.once.Do(func() { - frame, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()) - t.out <- frame - close(t.out) - close(t.settled) - }) -} -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"}} -} -func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - select { - case <-t.settled: - return agent.TurnSettlement{Reusable: true}, nil - case <-ctx.Done(): - return agent.TurnSettlement{}, ctx.Err() - } -} - -func executorRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} -} -func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { - t.Helper() - calls := &atomic.Int32{} - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary factory is forbidden") - }) - reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - calls.Add(1) - return owner, nil - }) - sender := &recSender{} - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: idle}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - owner.mu.Lock() - owner.closeErr = nil - owner.mu.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := router.Shutdown(ctx); err != nil { - t.Error(err) - } - }) - return router, sender, calls -} -func executorAdmission(t *testing.T, r *dispatch.Router, s *recSender, key string, req proto.ExecutionPreparePayload) proto.PreparationStatusPayload { - t.Helper() - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, key, req)); err != nil { - t.Fatal(err) - } - return waitPreparationStatus(t, s, key, "ready", "") -} -func startExecutorTurn(t *testing.T, r *dispatch.Router, s *recSender, key, id string, p proto.PreparationStatusPayload) { - t.Helper() - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, key, proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: id, Input: proto.TextInput("input")})); err != nil { - t.Fatal(err) - } - waitPreparationStatus(t, s, key, "started", "") -} -func TestExecutorRetainsOwnerAcrossIndependentTurns(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 2)} - r, s, calls := executorRouter(t, owner, time.Minute) - first := executorAdmission(t, r, s, "first", executorRequest()) - startExecutorTurn(t, r, s, "first", "one", first) - turn := <-owner.starts - turn.finish() - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "first Turn settled") - if owner.closes.Load() != 0 || turn.cancels.Load() != 0 { - t.Fatal("normal completion tore down or cancelled the executor") - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: first.Handle})) - req := executorRequest() - req.Configuration.AgentSessionID = "native-session" - req.Configuration.RequireExistingNativeSession = true - second := executorAdmission(t, r, s, "second", req) - if second.Handle == first.Handle || second.ExecutorID != first.ExecutorID || !second.Reused || calls.Load() != 1 { - t.Fatalf("reuse identities: first=%+v second=%+v calls=%d", first, second, calls.Load()) - } - startExecutorTurn(t, r, s, "second", "two", second) - next := <-owner.starts - _ = turn.Cancel(t.Context()) - if next.cancels.Load() != 0 { - t.Fatal("old Turn cancellation reached its successor") - } - next.finish() - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "second Turn settled") - if owner.closes.Load() != 0 { - t.Fatal("executor closed between Turns") - } -} -func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} - r, s, _ := executorRouter(t, owner, time.Minute) - p := executorAdmission(t, r, s, "first", executorRequest()) - startExecutorTurn(t, r, s, "first", "run", p) - turn := <-owner.starts - if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { - t.Fatal(err) - } - waitFor(t, func() bool { return len(cancellationAcks(s)) == 1 }, "cancellation receipt") - ack := cancellationAcks(s)[0] - if !ack.Applied || ack.Outcome == nil || turn.cancels.Load() != 1 || owner.closes.Load() != 0 { - t.Fatalf("cancel=%+v closes=%d", ack, owner.closes.Load()) - } -} -func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} - r, s, calls := executorRouter(t, owner, time.Minute) - p := executorAdmission(t, r, s, "first", executorRequest()) - startExecutorTurn(t, r, s, "first", "one", p) - (<-owner.starts).finish() - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "Turn settlement") - for _, kind := range []string{"configuration", "native"} { - req := executorRequest() - if kind == "configuration" { - req.Configuration.AgentOptions = map[string]any{"model": "changed"} - } else { - req.Configuration.AgentSessionID = "other-native" - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, kind, req)); err == nil { - t.Fatalf("accepted changed %s", kind) - } - } - if calls.Load() != 1 || owner.closes.Load() != 0 { - t.Fatal("conflict replaced executor") - } -} -func TestExecutorIdleExpiryClosesRetainedOwner(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} - r, s, _ := executorRouter(t, owner, 30*time.Millisecond) - p := executorAdmission(t, r, s, "first", executorRequest()) - startExecutorTurn(t, r, s, "first", "one", p) - (<-owner.starts).finish() - waitFor(t, func() bool { return owner.closes.Load() == 1 }, "idle executor close") -} -func TestExecutorPreInputFailureConfirmsCloseBeforeRetrySignal(t *testing.T) { - for _, unconfirmed := range []bool{false, true} { - t.Run(map[bool]string{false: "closed", true: "unconfirmed"}[unconfirmed], func(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 1), startErr: errors.New("native exited")} - if unconfirmed { - owner.closeErr = errors.New("cleanup unknown") - } - r, s, _ := executorRouter(t, owner, time.Minute) - p := executorAdmission(t, r, s, "first", executorRequest()) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "first", proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: "run", Input: proto.TextInput("input")})) - status := waitPreparationStatus(t, s, "first", "rejected", "") - want := "executor_unavailable" - if unconfirmed { - want = "executor_cleanup_unconfirmed" - } - if status.ErrorCode != want || owner.closes.Load() == 0 { - t.Fatalf("status=%+v closes=%d", status, owner.closes.Load()) - } - if unconfirmed { - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "retry", executorRequest())); err == nil { - t.Fatal("replaced unconfirmed owner") - } - } - }) - } -} - -func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, *recSender) { - t.Helper() - registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("unexpected legacy factory") - }) - registry.RegisterExecutor("reusable", factory) - sender := &recSender{} - router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, IdleTimeout: time.Minute}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := router.Shutdown(ctx); err != nil { - t.Error(err) - } - }) - return router, sender -} -func poolRequest(id string) proto.ExecutionPreparePayload { - req := executorRequest() - req.SessionID = id - req.Configuration.AgentStateKey = "agents-api-" + id - return req -} - -func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { - var mu sync.Mutex - var owners []*reusableExecutor - r, s := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - e := &reusableExecutor{} - mu.Lock() - owners = append(owners, e) - mu.Unlock() - return e, nil - }) - for i := 0; i < 17; i++ { - id := fmt.Sprint("idle-", i) - ready := executorAdmission(t, r, s, id, poolRequest(id)) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - } - waitFor(t, func() bool { - mu.Lock() - defer mu.Unlock() - var closed int32 - for _, e := range owners { - closed += e.closes.Load() - } - return closed == 1 - }, "idle capacity eviction") - for i := 0; i < 4; i++ { - id := fmt.Sprint("active-", i) - executorAdmission(t, r, s, id, poolRequest(id)) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", poolRequest("overflow"))); err == nil { - t.Fatal("active capacity exceeded") - } -} - -func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { - e := &reusableExecutor{} - r, s, calls := executorRouter(t, e, time.Minute) - executorAdmission(t, r, s, "one", executorRequest()) - req := executorRequest() - req.SessionID = "another" - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "scope", req)); err == nil { - t.Fatal("two sessions shared native state") - } - if calls.Load() != 1 { - t.Fatal("mismatched session invoked native preparation") - } -} - -func TestExecutorRejectsOutputFromAnotherTurn(t *testing.T) { - e := &reusableExecutor{starts: make(chan *reusableTurn, 1)} - r, s, _ := executorRouter(t, e, time.Minute) - p := executorAdmission(t, r, s, "one", executorRequest()) - startExecutorTurn(t, r, s, "one", "current", p) - turn := <-e.starts - wrong, _ := proto.NewEnvelope(proto.TypeDelta, "old-turn", proto.DeltaPayload{Delta: "late"}) - turn.out <- wrong - turn.finish() - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "invalid native event cleanup") - if e.closes.Load() != 1 { - t.Fatal("invalid event left executor reusable") - } - for _, frame := range s.snapshot() { - if frame.ID == "old-turn" { - t.Fatal("old Turn output escaped") - } - } -} - -func (*reusableTurn) SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error { - return agent.ErrSteeringRejected -} diff --git a/apps/parsar-daemon/internal/dispatch/functions.go b/apps/parsar-daemon/internal/dispatch/functions.go deleted file mode 100644 index 4b721f7b6..000000000 --- a/apps/parsar-daemon/internal/dispatch/functions.go +++ /dev/null @@ -1,74 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (r *Router) handleFunctionResult(ctx context.Context, env proto.Envelope) error { - var result proto.FunctionResultPayload - if err := env.DecodePayload(&result); err != nil { - return err - } - if env.ID == "" || result.CallID == "" || result.DeliveryID == "" { - return errors.New("function result requires run, call and delivery identities") - } - if err := result.ValidateContent(); err != nil { - return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, "invalid_result", err.Error()) - } - decision := result - decision.DeliveryID = "" - encoded, err := json.Marshal(decision) - if err != nil { - return err - } - fingerprint := sha256.Sum256(encoded) - // Scope receipt replay to both identities, even when native call IDs repeat across Runs. - kind := proto.TypeFunctionResult + "\x00" + result.CallID - if handled, err := r.replayAppliedInteractionDecision(ctx, env.ID, result.DeliveryID, kind, fingerprint); handled { - return err - } - 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") - } - if finishOperation != nil { - defer finishOperation() - var stop context.CancelFunc - ctx, stop = r.shutdownContext(ctx) - defer stop() - } - 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 { - 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 { - code := "runtime_error" - if errors.Is(err, agent.ErrUnsupportedOperation) { - code = "contract_violation" - } - if errors.Is(err, agent.ErrUnknownFunctionCall) { - code = "not_pending" - } - return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, false, code, "function result was not applied") - } - r.rememberAppliedInteractionDecision(env.ID, kind, fingerprint) - return r.sendInteractionDecisionAck(ctx, env.ID, result.DeliveryID, true, "", "") -} diff --git a/apps/parsar-daemon/internal/dispatch/functions_test.go b/apps/parsar-daemon/internal/dispatch/functions_test.go deleted file mode 100644 index c6bc1cc0a..000000000 --- a/apps/parsar-daemon/internal/dispatch/functions_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "image" - "image/color" - "image/png" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type functionSession struct { - *fakeSession - mu sync.Mutex - calls int -} - -func (s *functionSession) SubmitFunctionResult(_ context.Context, p proto.FunctionResultPayload) error { - s.mu.Lock() - defer s.mu.Unlock() - if p.CallID != "call" || s.calls != 0 { - return agent.ErrUnknownFunctionCall - } - s.calls++ - return nil -} -func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { - reg := agent.NewRegistry() - sender := &recSender{} - sessions := map[string]*functionSession{} - reg.RegisterKind(proto.SupportedAgentKind{Kind: "function-test", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, p proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - s := &functionSession{fakeSession: &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}} - sessions[p.RunID] = s - return s, nil - }) - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer router.Shutdown(context.Background()) - for _, id := range []string{"one", "two"} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, id, proto.PromptRequestPayload{AgentKind: "function-test", Input: proto.TextInput("lookup"), FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } - } - submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { - t.Helper() - env, _ := proto.NewEnvelope(proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: call, Success: true, Content: functionResultContent(text), DeliveryID: delivery}) - if err := router.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } - frames := sender.snapshot() - last := frames[len(frames)-1] - var ack proto.InteractionDecisionAckPayload - if err := last.DecodePayload(&ack); err != nil || last.Type != proto.TypeInteractionDecisionAck || last.ID != run || ack.DeliveryID != delivery { - t.Fatal(last, err) - } - return ack - } - if a := submit("missing", "call", "answer", "a"); a.Applied || a.ErrorCode != "not_pending" { - t.Fatal(a) - } - if a := submit("one", "missing", "answer", "b"); a.Applied || a.ErrorCode != "not_pending" { - t.Fatal(a) - } - - invalid, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", DeliveryID: "invalid", Content: []proto.InputContent{{Type: "input_audio"}}}) - if err := router.Handle(t.Context(), invalid); err != nil { - t.Fatal(err) - } - frames := sender.snapshot() - var invalidAck proto.InteractionDecisionAckPayload - _ = frames[len(frames)-1].DecodePayload(&invalidAck) - if invalidAck.Applied || invalidAck.ErrorCode != "invalid_result" { - t.Fatal(invalidAck) - } - // A lost receipt may be retried without writing the native result twice. - sender.mu.Lock() - sender.failNow = true - sender.mu.Unlock() - first, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "lost"}) - if err := router.Handle(t.Context(), first); err == nil { - t.Fatal("receipt send failure was hidden") - } - if a := submit("one", "call", "answer", "retry"); !a.Applied { - t.Fatal(a) - } - if a := submit("one", "call", "changed", "conflict"); a.Applied || a.ErrorCode != "decision_conflict" { - t.Fatal(a) - } - - for _, mutation := range []string{"image", "order", "success"} { - result := proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: mutation} - switch mutation { - case "image": - other := "https://example.com/other.png" - result.Content[1].ImageURL = &other - case "order": - result.Content[0], result.Content[1] = result.Content[1], result.Content[0] - case "success": - result.Success = false - } - env, _ := proto.NewEnvelope(proto.TypeFunctionResult, "one", result) - if err := router.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } - frames := sender.snapshot() - var ack proto.InteractionDecisionAckPayload - _ = frames[len(frames)-1].DecodePayload(&ack) - if ack.Applied || ack.ErrorCode != "decision_conflict" { - t.Fatal(mutation, ack) - } - } - if a := submit("two", "call", "second answer", "other-run"); !a.Applied { - t.Fatal(a) - } - for _, s := range sessions { - s.mu.Lock() - count := s.calls - s.mu.Unlock() - if count != 1 { - t.Fatal(count) - } - } -} - -func TestFunctionToolsRequireAdvertisedSupport(t *testing.T) { - reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "unsupported", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil - }) - sender := &recSender{} - router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - defer router.Shutdown(context.Background()) - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run", proto.PromptRequestPayload{AgentKind: "unsupported", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("unsupported engine silently ignored tools", err) - } -} - -func functionResultContent(text string) []proto.InputContent { - picture := image.NewRGBA(image.Rect(0, 0, 1, 1)) - picture.Set(0, 0, color.RGBA{R: 255, A: 255}) - var encoded bytes.Buffer - if err := png.Encode(&encoded, picture); err != nil { - panic(err) - } - imageURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()) - after := "AFTER-IMAGE" - return []proto.InputContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &imageURL}, {Type: "input_text", Text: &after}} -} - -func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { - reg := agent.NewRegistry() - called := false - reg.RegisterKind(proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - called = true - return nil, nil - }) - router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: &recSender{}}) - defer router.Shutdown(context.Background()) - for _, search := range []bool{false, true} { - env, _ := proto.NewEnvelope(proto.TypePromptRequest, "discovery", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) - if err := router.Handle(t.Context(), env); err == nil || called { - t.Fatal("deferred definitions reached an eager-only adapter", err) - } - } -} diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http.go b/apps/parsar-daemon/internal/dispatch/mcp_http.go deleted file mode 100644 index 8f0495f53..000000000 --- a/apps/parsar-daemon/internal/dispatch/mcp_http.go +++ /dev/null @@ -1,37 +0,0 @@ -package dispatch - -import ( - "errors" - "net/url" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Validate combined placement and authentication before factory selection. -func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { - if req.MCPHTTPServers == nil { - return nil - } - for _, server := range *req.MCPHTTPServers { - if err := server.ValidateConnectionOrigin(req); err != nil { - return err - } - if !caps.MCPHTTPTools.IsSupported() { - return errors.New("engine does not support HTTP MCP") - } - if server.Required && !caps.MCPHTTPRequired.IsSupported() { - return errors.New("engine does not support required HTTP MCP initialization") - } - if server.BearerToken == nil { - continue - } - if !caps.MCPHTTPTools.IsSupported() || !caps.MCPHTTPBearerAuth.IsSupported() { - return errors.New("engine does not support authenticated HTTP MCP") - } - endpoint, err := url.Parse(server.ServerURL) - if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" { - return errors.New("authenticated HTTP MCP requires HTTPS") - } - } - return nil -} diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go deleted file mode 100644 index bac2d4c2a..000000000 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { - for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { - t.Run(mode, func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - token := "synthetic-private-token" - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} - req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} - caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported}) - switch mode { - case "claude", "claude old peer", "claude local": - req.AgentKind = "claude_sdk" - caps.MCPHTTPBearerAuth = proto.CapabilityFromBool(mode != "claude old peer") - if mode == "claude local" { - req.DisableExecutionEnvironment = false - } - case "required", "required old peer", "optional old peer": - servers[0].Required = mode != "optional old peer" - servers[0].BearerToken = nil - caps.MCPHTTPRequired = proto.CapabilityFromBool(mode == "required") - case "no bearer capability", "credential-free", "product": - caps.MCPHTTPBearerAuth = proto.CapabilityUnsupported - case "no MCP capability": - caps.MCPHTTPTools = proto.CapabilityUnsupported - case "no none capability": - caps.EnvironmentNone = proto.CapabilityUnsupported - case "local": - req.DisableExecutionEnvironment = false - case "other engine": - req.AgentKind = "other" - case "HTTP": - servers[0].ServerURL = "http://tools.example/mcp" - } - if mode == "credential-free" { - servers[0].BearerToken = nil - } - if mode == "product" { - req.MCPHTTPServers, req.DisableExecutionEnvironment = nil, false - } - called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.AgentKind, Available: mode != "unavailable", Capabilities: caps}, - harnessconfig.Configuration{}, func(_ context.Context, got proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { - called = true - if mode == "required" && !(*got.MCPHTTPServers)[0].Required { - t.Error("required initialization lost before adapter") - } - if (mode == "supported" || mode == "claude") && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != token) { - t.Error("token lost before adapter") - } - return nil, errors.New("controlled factory stop") - }) - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) - if called != (mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { - t.Fatal("wrong factory admission") - } - frames := h.sender.snapshot() - raw, _ := json.Marshal(frames) - if err == nil || strings.Contains(err.Error(), token) || strings.Contains(string(raw), token) || len(frames) != 2 || frames[0].Type != proto.TypeError || frames[1].Type != proto.TypeDone { - t.Fatal("terminal rejection missing or exposed credential") - } - }) - } -} - -func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { - for _, mode := range []string{"anonymous", "bearer", "required", "empty declaration", "no declaration", "environment anonymous", "environment bearer", "environment required", "environment missing capability"} { - t.Run(mode, func(t *testing.T) { - h := localPreparationHarness(t) - defer h.router.Shutdown(context.Background()) - req := preparationRequest() - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} - req.Configuration.MCPHTTPServers = &servers - if strings.HasPrefix(mode, "environment") { - servers[0].ConnectionOrigin = "environment" - req.Configuration.LocalEnvironment.NetworkAccess = "enabled" - } - if strings.Contains(mode, "bearer") { - token := "synthetic-private-token" - servers[0].BearerToken = &token - } - if strings.Contains(mode, "required") { - servers[0].Required = true - } - if mode == "empty declaration" { - servers = []proto.MCPHTTPServer{} - } - if mode == "no declaration" { - req.Configuration.MCPHTTPServers = nil - } - entered := make(chan struct{}, 1) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(mode != "environment missing capability"), MCPHTTPBearerAuth: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - t.Error("ordinary factory called") - return nil, errors.New("unexpected") - }) - h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - entered <- struct{}{} - return nil, errors.New("controlled stop") - }) - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local-mcp", req)) - allowed := mode == "no declaration" || mode == "empty declaration" || strings.HasPrefix(mode, "environment") && mode != "environment missing capability" - if (err == nil) != allowed { - t.Fatal("wrong preparation admission", err) - } - if allowed { - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("factory not called") - } - waitPreparationStatus(t, h.sender, "local-mcp", "failed", "") - } else { - select { - case <-entered: - t.Fatal("rejected request reached factory") - default: - } - } - }) - } -} diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go deleted file mode 100644 index 51c959c84..000000000 --- a/apps/parsar-daemon/internal/dispatch/preparation.go +++ /dev/null @@ -1,307 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -const preparationCapacity = 4 -const preparationRecords = 64 - -// All mutable fields are protected by Router.mu. owns includes resources whose -// cancellation is underway; a slow close cannot bypass the capacity bound. -type preparationState struct { - capabilities proto.AgentKindCapabilities - executor *executorState - requestID string - trace string - fingerprint [32]byte - startFingerprint [32]byte - status proto.PreparationStatusPayload - deadline time.Time - timer *time.Timer - ctx context.Context - cancel context.CancelFunc - prepared agent.Prepared - stateKey string - environmentID string - busy bool - owns bool - closeErr error - workspaceReadOnly bool - handoff *preparedHandoff -} - -func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) error { - var input proto.ExecutionPreparePayload - if env.DecodePayload(&input) != nil || strings.TrimSpace(env.ID) == "" { - return r.rejectPreparation(env, "invalid_request") - } - req := input.Configuration - if !req.WorkspaceReadOnly { - return r.handleExecutorPrepare(ctx, env, input) - } - caps, available := r.availableCapabilities(req.AgentKind) - if !available { - return r.rejectPreparation(env, "resource_unavailable") - } - prepare, err := r.registry.ResolvePreparation(req.AgentKind) - if err != nil || !caps.Preparation.IsSupported() { - return r.rejectPreparation(env, "unsupported_preparation") - } - if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req)) { - return r.rejectPreparation(env, "unsupported_read_preparation") - } - if req, err = r.localWorkspace.Configure(req); err != nil { - return r.rejectPreparation(env, "invalid_configuration") - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { - return r.rejectPreparation(env, "invalid_configuration") - } - if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { - return r.rejectPreparation(env, "unsupported_configuration") - } - if req.LocalEnvironment != nil && req.WorkspaceReadOnly { - prepare = prepareLocalDirectory - } - encoded, err := json.Marshal(req) - if err != nil { - return r.rejectPreparation(env, "invalid_configuration") - } - fingerprint := sha256.Sum256(encoded) - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return ErrRouterClosed - } - if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { - r.mu.Unlock() - return r.rejectPreparation(env, "resource_unavailable") - } - r.prunePreparationsLocked() - if old := r.preparationRequests[env.ID]; old != nil { - status := old.status - matches := old.fingerprint == fingerprint - r.mu.Unlock() - if !matches { - return r.rejectPreparation(env, "request_conflict") - } - r.publishPreparation(old, status) - return nil - } - owned := 0 - for _, p := range r.preparations { - if p.owns { - owned++ - } - } - if owned >= preparationCapacity || len(r.preparations) >= preparationRecords { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_capacity") - } - owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} - p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} - r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) - r.shutdownWG.Add(1) - r.mu.Unlock() - go r.prepareExecution(p, req, prepare) - return nil -} - -func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { - defer r.shutdownWG.Done() - if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) - } - var prepared agent.Prepared - var err error - if p.ctx.Err() == nil { - prepared, err = prepare(p.ctx, req) - } else { - err = p.ctx.Err() - } - if err == nil && prepared != nil && !p.workspaceReadOnly { - if _, ok := prepared.(agent.PreparedCancellation); !ok { - err = errors.New("executable preparation requires cross-transfer cancellation") - } - } - r.mu.Lock() - p.busy = false - p.prepared = prepared - ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed - if ready { - p.status.State, p.status.Revision = "ready", p.status.Revision+1 - } else if p.status.State == "preparing" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 - } - status := p.status - if !ready { - p.busy = true - p.cancel() - p.timer.Stop() - } - r.mu.Unlock() - if !ready { - r.closePreparationResource(p) - if p.workspaceReadOnly { - return - } - r.mu.Lock() - status = p.status - r.mu.Unlock() - } - if !r.sendPreparation(p.requestID, p.trace, status) && ready { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) - } -} - -func (r *Router) handleExecutionRelease(_ context.Context, env proto.Envelope) error { - var input proto.ExecutionReleasePayload - if env.DecodePayload(&input) != nil || input.Handle == "" { - return r.rejectPreparation(env, "invalid_release") - } - r.mu.Lock() - p := r.preparations[input.Handle] - valid := p != nil && p.requestID == env.ID - r.mu.Unlock() - if !valid { - return r.rejectPreparation(env, "unknown_preparation") - } - r.releasePreparation(p, "released", "", true, true) - return nil -} - -func (r *Router) releasePreparation(p *preparationState, state, code string, publish, retryHandoff bool) { - r.mu.Lock() - if r.closed || r.suspension != nil { - r.mu.Unlock() - return - } - if p.executor != nil { - r.mu.Unlock() - r.abandonExecutorAdmission(p, state, code, publish) - return - } - if p.handoff != nil { - if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { - if state == "expired" && p.handoff.published { - r.mu.Unlock() - return - } - switch p.status.State { - case "preparing", "ready", "starting", "started": - p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 - p.timer.Stop() - } - r.claimPreparedReleaseLocked(active, true, "", retryHandoff) - status := p.status - r.mu.Unlock() - if publish { - r.publishPreparation(p, status) - } - return - } - } - closeResource := false - switch p.status.State { - case "preparing", "ready", "starting": - p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 - p.cancel() - p.timer.Stop() - } - if p.owns && !p.busy { - if p.workspaceReadOnly && state == "released" && p.status.ErrorCode == "cleanup_unconfirmed" { - p.status.State, p.status.ErrorCode, p.status.Revision = state, "", p.status.Revision+1 - } - p.busy = true - closeResource = true - r.shutdownWG.Add(1) - } - status := p.status - settled := !p.owns && !p.busy - r.mu.Unlock() - if closeResource { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } - if publish && (!p.workspaceReadOnly || settled) { - r.publishPreparation(p, status) - } -} - -func (r *Router) prunePreparationsLocked() { - var oldest *preparationState - for handle, p := range r.preparations { - if p.owns { - continue - } - if time.Now().After(p.deadline) { - delete(r.preparations, handle) - delete(r.preparationRequests, p.requestID) - } else if oldest == nil || p.deadline.Before(oldest.deadline) { - oldest = p - } - } - if len(r.preparations) >= preparationRecords && oldest != nil { - delete(r.preparations, oldest.status.Handle) - delete(r.preparationRequests, oldest.requestID) - } -} - -func (r *Router) publishPreparation(p *preparationState, status proto.PreparationStatusPayload) { - r.mu.Lock() - if p.workspaceReadOnly { - if status.Revision != p.status.Revision || (p.owns && (p.busy || status.State == "released" || status.State == "expired") && status.State != "preparing" && status.State != "ready") { - r.mu.Unlock() - return - } - } - if r.closed || r.suspension != nil { - r.mu.Unlock() - return - } - r.shutdownWG.Add(1) - r.mu.Unlock() - go func() { - defer r.shutdownWG.Done() - // A failed terminal notification must not restart incomplete cleanup. - if !r.sendPreparation(p.requestID, p.trace, status) && (!p.workspaceReadOnly || status.State == "preparing" || status.State == "ready") { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) - } - }() -} - -func (r *Router) sendPreparation(requestID, trace string, status proto.PreparationStatusPayload) bool { - return r.sendPreparationUntil(requestID, trace, status, time.Now().Add(5*time.Second)) -} - -func (r *Router) sendPreparationUntil(requestID, trace string, status proto.PreparationStatusPayload, deadline time.Time) bool { - ctx, stop := r.shutdownContext(context.Background()) - defer stop() - ctx, cancel := context.WithDeadline(ctx, deadline) - defer cancel() - env, err := proto.NewEnvelopeWithTrace(proto.TypePreparationStatus, requestID, status, trace) - return err == nil && r.sender.Send(ctx, env) == nil -} - -func (r *Router) rejectPreparation(env proto.Envelope, code string) error { - r.mu.Lock() - if !r.closed { - r.shutdownWG.Add(1) - go func() { - defer r.shutdownWG.Done() - r.sendPreparation(env.ID, env.Trace, proto.PreparationStatusPayload{State: "rejected", ErrorCode: code, Operation: env.Type}) - }() - } - r.mu.Unlock() - return errors.New("dispatch: " + code) -} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go deleted file mode 100644 index c6a5ddbc5..000000000 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "os" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type controlledPreparation struct { - closed chan struct{} - once sync.Once - mu sync.Mutex - session agent.Session - starts atomic.Int32 - start func(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) - closeHook func() -} - -func (p *controlledPreparation) Close() error { - p.once.Do(func() { - if p.closeHook != nil { - p.closeHook() - } - if p.closed != nil { - close(p.closed) - } - }) - return nil -} -func (p *controlledPreparation) Start(ctx context.Context, id string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.starts.Add(1) - session, err := p.start(ctx, id, prompt, out) - if session != nil { - p.mu.Lock() - p.session = session - p.mu.Unlock() - } - return session, err -} - -func (p *controlledPreparation) Cancel(ctx context.Context) error { - p.mu.Lock() - session := p.session - p.mu.Unlock() - if session != nil { - return session.Cancel(ctx) - } - return p.Close() -} - -func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - session := p.session - p.mu.Unlock() - if provider, ok := session.(interface{ CancellationOutcome() proto.DonePayload }); ok { - return provider.CancellationOutcome() - } - return proto.DonePayload{} -} - -const preparationEnvironmentID = "11111111-1111-4111-8111-111111111111" -const preparationSessionID = "22222222-2222-4222-8222-222222222222" - -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, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - "OAC_RUNTIME_ALLOWED_DOMAINS": "", - } { - t.Setenv(name, value) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - return binding -} - -func localPreparationHarness(t *testing.T) *harness { - t.Helper() - h := newHarness(t) - if err := h.router.Shutdown(t.Context()); err != nil { - t.Fatal(err) - } - var err error - h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, LocalWorkspace: preparationWorkspace(t)}) - if err != nil { - t.Fatal(err) - } - return h -} - -func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} -} - -func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { - t.Helper() - reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("ordinary Factory must not be used for preparation") - }) - reg.RegisterPreparation("prepared", true, factory) - reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if err := r.Shutdown(ctx); err != nil { - t.Error(err) - } - }) - return r -} - -func waitPreparationStatus(t *testing.T, sender *recSender, request, state string, differentHandle string) proto.PreparationStatusPayload { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - for _, env := range sender.snapshot() { - var p proto.PreparationStatusPayload - if env.Type == proto.TypePreparationStatus && env.ID == request && env.DecodePayload(&p) == nil && p.State == state && (differentHandle == "" || p.Handle != differentHandle) { - return p - } - } - time.Sleep(time.Millisecond) - } - t.Fatalf("preparation %s did not reach %s", request, state) - return proto.PreparationStatusPayload{} -} - -func waitPreparationClosed(t *testing.T, p *controlledPreparation) { - t.Helper() - select { - case <-p.closed: - case <-time.After(3 * time.Second): - t.Fatal("native preparation leaked") - } -} - -func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { - sender := &recSender{} - p := &controlledPreparation{closed: make(chan struct{})} - entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) - r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if req.RunID != "" || len(req.Input) != 0 { - t.Error("run input reached preparation") - } - entered <- ctx - <-allowReturn - return p, nil - }) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { - t.Fatal(err) - } - accepted := waitPreparationStatus(t, sender, "request", "preparing", "") - owner := <-entered - if r.ActiveRuns() != 0 { - t.Fatal("preparation created a run") - } - // Receive-loop operations remain available while native initialization blocks. - if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "other-run", proto.PromptCancelPayload{})); err != nil { - t.Fatal(err) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: accepted.Handle})); err != nil { - t.Fatal(err) - } - select { - case <-owner.Done(): - case <-time.After(time.Second): - t.Fatal("release did not cancel owner") - } - close(allowReturn) - waitPreparationClosed(t, p) - if p.starts.Load() != 0 { - t.Fatal("released preparation started work") - } - for _, env := range sender.snapshot() { - if env.Type == proto.TypeError || env.Type == proto.TypeDone { - t.Fatal("preparation emitted run terminal frames") - } - } -} - -func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { - sender := &recSender{} - gotSession := make(chan *fakeSession, 1) - p := &controlledPreparation{closed: make(chan struct{})} - p.start = func(ctx context.Context, id string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if id != "real-run" || *prompt[0].Content[0].Text != "actual input" { - t.Error("start identity or prompt changed") - } - s := &fakeSession{ctx: ctx, out: out, closeOutOnCancel: true} - gotSession <- s - return s, nil - } - r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) - if err := r.Handle(t.Context(), prepare); err != nil { - t.Fatal(err) - } - ready := waitPreparationStatus(t, sender, "request", "ready", "") - if err := r.Handle(t.Context(), prepare); err != nil { - t.Fatal(err) - } - start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("actual input")}) - if err := r.Handle(t.Context(), start); err != nil { - t.Fatal(err) - } - waitPreparationStatus(t, sender, "request", "started", "") - session := <-gotSession - if err := r.Handle(t.Context(), start); err != nil { - t.Fatal(err) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - // The old preparation deadline must not govern the transferred Session. - time.Sleep(100 * time.Millisecond) - if session.ctx.Err() != nil || session.cancels() != 0 || p.starts.Load() != 1 || r.ActiveRuns() != 1 { - t.Fatal("transfer was duplicated or cancelled") - } - select { - case <-p.closed: - t.Fatal("transferred preparation closed") - default: - } - session.out <- mustEnv(t, proto.TypeDone, "real-run", proto.DonePayload{Content: "complete"}) - deadline := time.Now().Add(time.Second) - for r.ActiveRuns() != 0 && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if r.ActiveRuns() != 0 || session.cancels() != 1 { - t.Fatal("normal completion release was bypassed") - } -} - -func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { - sender := &recSender{} - entered, cancelEntered, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) - lateSession := make(chan *fakeSession, 1) - p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}} - p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - close(entered) - <-allowReturn - s := &fakeSession{out: out, closeOutOnCancel: true} - lateSession <- s - return s, nil - } - p.cancel = func(ctx context.Context) error { - close(cancelEntered) - return (<-lateSession).Cancel(ctx) - } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) - ready := waitPreparationStatus(t, sender, "request", "ready", "") - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) - <-entered - if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "real-run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { - t.Fatal(err) - } - select { - case <-cancelEntered: - case <-time.After(time.Second): - t.Fatal("fixed cancellation target was not called across Start") - } - close(allowReturn) - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "late cancelled Session cleanup") - p.mu.Lock() - session := p.session.(*fakeSession) - p.mu.Unlock() - if session.cancels() != 1 || r.ActiveRuns() != 0 { - t.Fatal("late session resurrected cancelled run") - } - select { - case <-p.controlledPreparation.closed: - t.Fatal("transferred preparation was released a second time") - default: - } - for _, frame := range sender.snapshot() { - var status proto.PreparationStatusPayload - if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "started" { - t.Fatal("cancelled start published active Session") - } - } -} - -func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { - sender := &recSender{} - created := make(chan *controlledPreparation, 2) - r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - p := &controlledPreparation{closed: make(chan struct{})} - created <- p - return p, nil - }) - env := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) - _ = r.Handle(t.Context(), env) - old := waitPreparationStatus(t, sender, "request", "ready", "") - waitPreparationStatus(t, sender, "request", "expired", "") - owner := <-created - select { - case <-owner.closed: - t.Fatal("admission expiry closed a healthy executor") - default: - } - _ = r.Handle(t.Context(), env) - next := waitPreparationStatus(t, sender, "request", "ready", old.Handle) - if next.Handle == old.Handle || next.ExpiresAt <= old.ExpiresAt { - t.Fatal("replacement reused expired identity") - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: old.Handle, ExecutorID: old.ExecutorID, RunID: "late", Input: proto.TextInput("late")})); err == nil { - t.Fatal("old handle started replacement") - } -} - -type failReadySender struct{ *recSender } - -func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { - var status proto.PreparationStatusPayload - if env.Type == proto.TypePreparationStatus && env.DecodePayload(&status) == nil && status.State == "ready" { - return errors.New("controlled send failure") - } - return s.recSender.Send(ctx, env) -} - -func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { - created := make(chan struct{}) - p := &controlledPreparation{closed: make(chan struct{})} - r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - close(created) - return p, nil - }) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) - <-created - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "abandoned admission") - if err := r.Shutdown(t.Context()); err != nil { - t.Fatal(err) - } - waitPreparationClosed(t, p) - if r.ActiveRuns() != 0 { - t.Fatal("failed preparation became a run") - } -} - -func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { - for name, change := range map[string]func(*proto.PromptRequestPayload){ - "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, - "input": func(p *proto.PromptRequestPayload) { p.Input = proto.TextInput("input") }, - "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, - "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, - "attachment": func(p *proto.PromptRequestPayload) { - p.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} - }, - "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, - "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, - } { - t.Run(name, func(t *testing.T) { - r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - t.Error("invalid preparation reached native factory") - return nil, errors.New("invalid") - }) - req := preparationRequest() - change(&req.Configuration) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", req)); err == nil { - t.Fatal("invalid preparation accepted") - } - if r.ActiveRuns() != 0 { - t.Fatal("invalid configuration became a Run") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/dispatch/steering.go b/apps/parsar-daemon/internal/dispatch/steering.go deleted file mode 100644 index f3df33dd3..000000000 --- a/apps/parsar-daemon/internal/dispatch/steering.go +++ /dev/null @@ -1,204 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// Retain all attempts for the active run; reject overflow rather than evicting -// receipts and risking a duplicate native input. Durable recovery is server-owned. -const ( - maxSteeringInputs = 256 - steeringCallTimeout = 10 * time.Second - steeringSendTimeout = 5 * time.Second -) - -type steeringReceipt struct { - fingerprint [32]byte - ack proto.PromptSteerAckPayload - durable bool -} - -func (r *Router) handlePromptSteer(ctx context.Context, env proto.Envelope) error { - var input proto.PromptSteerPayload - ack := proto.PromptSteerAckPayload{} - if err := env.DecodePayload(&input); err != nil { - ack.ErrorCode, ack.Error = "invalid_input", "Invalid steering payload." - } else { - ack.InputID = input.InputID - if env.ID == "" || strings.TrimSpace(input.InputID) == "" || len(input.InputID) > 256 || input.Input.Validate() != nil { - ack.ErrorCode, ack.Error = "invalid_input", "Run ID, input ID (up to 256 bytes), and non-empty text are required." - } else { - pending := r.queueSteering(ctx, env, input) - if pending == nil { - return nil - } - ack = *pending - } - } - return r.sendSteeringAck(ctx, env, ack) -} - -func (r *Router) sendSteeringAck(ctx context.Context, env proto.Envelope, ack proto.PromptSteerAckPayload) error { - reply, err := proto.NewEnvelopeWithTrace(proto.TypePromptSteerAck, env.ID, ack, env.Trace) - if err != nil { - return err - } - return r.sender.Send(ctx, reply) -} - -func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input proto.PromptSteerPayload) *proto.PromptSteerAckPayload { - ack := proto.PromptSteerAckPayload{InputID: input.InputID} - r.mu.Lock() - defer r.mu.Unlock() - state := r.sessions[env.ID] - if state == nil || r.closed { - ack.ErrorCode, ack.Error = "run_inactive", "The run is no longer active." - return &ack - } - encoded, _ := json.Marshal(input.Input) - fingerprint := sha256.Sum256(encoded) - if previous, ok := state.steering[input.InputID]; ok { - if previous.fingerprint != fingerprint || previous.durable != input.DurableReceipt { - ack.ErrorCode, ack.Error = "input_conflict", "This input ID was already used with different text." - return &ack - } - if state.steeringClosed || previous.ack.ErrorCode != "not_ready" && previous.ack.ErrorCode != "busy" { - return &previous.ack - } - } else if len(state.steering) >= maxSteeringInputs { - ack.ErrorCode, ack.Error = "input_limit", "The active run has reached its steering input limit." - return &ack - } - if state.steeringClosed { - ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." - return &ack - } - if state.steering == nil { - state.steering = make(map[string]steeringReceipt) - } - ack.ErrorCode, ack.Error = "not_ready", "The run is still starting." - // Bind input identity before any retryable state so changed text cannot - // slip through a startup or in-flight retry. - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} - if state.session == nil { - return &ack - } - // The admitted declaration is immutable even if discovery changes later. - if input.DurableReceipt && !state.capabilities.DurableInputReceipts.IsSupported() || !input.DurableReceipt && !state.capabilities.Steering.IsSupported() { - ack.ErrorCode, ack.Error = "unsupported", "The runtime declaration does not support this input operation." - return &ack - } - session := state.session - steerer, supportsSteering := session.(agent.Steerer) - if input.DurableReceipt { - if _, ok := session.(agent.DurableSteerer); !ok || (!state.releaseOnCompletion && state.preparedHandoff == nil) { - ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." - return &ack - } - } else if !supportsSteering { - ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." - return &ack - } - if state.steerBusy { - ack.ErrorCode, ack.Error = "busy", "Another input is awaiting an engine receipt; retry this input later." - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} - return &ack - } - session, finishOperation, ready := r.preparedOperationLocked(state) - if !ready { - ack.ErrorCode, ack.Error = "run_inactive", "The run is completing." - return &ack - } - ack.ErrorCode, ack.Error = "in_flight", "This input is awaiting an engine receipt." - state.steering[input.InputID] = steeringReceipt{fingerprint: fingerprint, ack: ack, durable: input.DurableReceipt} - state.steerBusy = true - finished := make(chan struct{}) - state.steeringDone = finished - r.shutdownWG.Add(1) - go func() { - defer r.shutdownWG.Done() - defer finishOperation() - defer func() { - r.mu.Lock() - state.steerBusy = false - close(finished) - r.mu.Unlock() - }() - ctx, stop := r.shutdownContext(ctx) - defer stop() - var err error - if input.DurableReceipt { - err = r.steerDurably(ctx, state, session, env, input, fingerprint) - } else { - callCtx, cancel := context.WithTimeout(ctx, steeringCallTimeout) - err = steerer.Steer(callCtx, input) - cancel() - } - r.publishSteeringReceipt(ctx, state, env, input, fingerprint, steeringResult(input.InputID, err)) - }() - return nil -} - -func steeringResult(inputID string, err error) proto.PromptSteerAckPayload { - ack := proto.PromptSteerAckPayload{InputID: inputID} - switch { - case errors.Is(err, agent.ErrUnsupportedOperation): - ack.ErrorCode, ack.Error = "contract_violation", "Declared input capability has no implementation." - case errors.Is(err, agent.ErrSteeringNotReady): - ack.ErrorCode, ack.Error = "not_ready", err.Error() - case errors.Is(err, agent.ErrSteeringInactive): - ack.ErrorCode, ack.Error = "run_inactive", err.Error() - case errors.Is(err, agent.ErrSteeringRejected): - ack.ErrorCode, ack.Error = "rejected", err.Error() - case err != nil: - // A timeout or broken connection may follow native acceptance. Preserve - // the uncertainty and never automatically send this input again. - ack.ErrorCode, ack.Error = "outcome_unknown", err.Error() - default: - ack.Accepted = true - } - return ack -} - -// shutdownContext releases cooperating work when the router shuts down. -func (r *Router) shutdownContext(parent context.Context) (context.Context, context.CancelFunc) { - ctx, cancel := context.WithCancel(parent) - go func() { - select { - case <-r.shutdownCh: - cancel() - case <-ctx.Done(): - } - }() - return ctx, cancel -} - -// Close admission before taking the last worker: its lifetime includes the -// receipt send, so a durable Done cannot close the gateway subscription first. -func (r *Router) finishSteering(state *sessionState) error { - r.mu.Lock() - state.steeringClosed = true - finished := state.steeringDone - r.mu.Unlock() - if finished == nil { - return nil - } - timer := time.NewTimer(steeringCallTimeout + steeringSendTimeout) - defer timer.Stop() - select { - case <-finished: - return nil - case <-r.shutdownCh: - return context.Canceled - case <-timer.C: - return context.DeadlineExceeded - } -} diff --git a/apps/parsar-daemon/internal/dispatch/steering_test.go b/apps/parsar-daemon/internal/dispatch/steering_test.go deleted file mode 100644 index 5ec852417..000000000 --- a/apps/parsar-daemon/internal/dispatch/steering_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package dispatch_test - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type steeringSession struct { - *fakeSession - steer func(context.Context, proto.PromptSteerPayload) error -} - -func (s *steeringSession) Steer(ctx context.Context, input proto.PromptSteerPayload) error { - return s.steer(ctx, input) -} - -func TestSteeringReceiptsAndRetries(t *testing.T) { - for _, engineError := range []error{nil, errors.New("native connection lost after write")} { - name := "accepted" - if engineError != nil { - name = "uncertain" - } - t.Run(name, func(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - calls, starts := 0, 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - starts++ - return &steeringSession{ - fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, - steer: func(ctx context.Context, input proto.PromptSteerPayload) error { - calls++ - if _, ok := ctx.Deadline(); !ok { - t.Error("native request has no deadline") - } - if input.InputID != "input-1" || *input.Input[0].Content[0].Text != "additional text" { - t.Errorf("input lost: %+v", input) - } - if len(h.sender.snapshot()) != 0 { - t.Error("ack sent before engine accepted input") - } - return engineError - }, - }, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } - input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("additional text")} - env := mustEnv(t, proto.TypePromptSteer, "run-1", input) - // An ack transport failure must not cause another native invocation. - h.sender.failNow = true - if err := h.router.Handle(ctx, env); err != nil { - t.Fatal(err) - } - waitFor(t, func() bool { h.sender.mu.Lock(); defer h.sender.mu.Unlock(); return !h.sender.failNow }, "failed ack send") - for range 2 { - if err := handleSteeringAndWait(t, h, env); err != nil { - t.Fatal(err) - } - ack := lastSteeringAck(t, h.sender, "run-1", "input-1") - if ack.Accepted != (engineError == nil) { - t.Fatalf("receipt: %+v", ack) - } - if engineError != nil && ack.ErrorCode != "outcome_unknown" { - t.Fatalf("uncertainty lost: %+v", ack) - } - } - input.Input = proto.TextInput("changed text") - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { - t.Fatalf("conflict: %+v", ack) - } - if calls != 1 || starts != 1 { - t.Fatalf("native calls=%d, runs started=%d", calls, starts) - } - }) - } -} - -func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return &steeringSession{ - fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, - steer: func(context.Context, proto.PromptSteerPayload) error { - calls++ - if calls == 1 { - return agent.ErrSteeringNotReady - } - return nil - }, - }, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } - input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("extra")} - env := mustEnv(t, proto.TypePromptSteer, "run-1", input) - for _, expected := range []string{"not_ready", ""} { - if err := handleSteeringAndWait(t, h, env); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != expected { - t.Fatalf("expected %q: %+v", expected, ack) - } - if expected == "not_ready" { - changed := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("different during startup")} - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", changed)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "input_conflict" { - t.Fatalf("startup identity changed: %+v", ack) - } - } - } - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-1", nil)); err != nil { - t.Fatal(err) - } - waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "cancel cleanup") - if err := handleSteeringAndWait(t, h, env); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { - t.Fatalf("inactive: %+v", ack) - } - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { - t.Fatal(err) - } - session := <-h.gotSess - defer close(session.out) - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "unsupported" { - t.Fatalf("unsupported: %+v", ack) - } - input.Input = proto.TextInput("") - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-2", "input-1"); ack.ErrorCode != "invalid_input" { - t.Fatalf("invalid: %+v", ack) - } - // Whitespace-only text is content: dispatch forwards it like any other text. - input.InputID, input.Input = "input-2", proto.TextInput(" \n ") - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-2", input)); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-2", "input-2"); ack.ErrorCode != "unsupported" { - t.Fatalf("whitespace: %+v", ack) - } -} - -func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - entered, release := make(chan struct{}), make(chan struct{}) - defer close(release) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return &steeringSession{ - fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, - steer: func(context.Context, proto.PromptSteerPayload) error { - close(entered) - <-release - return agent.ErrSteeringRejected - }, - }, nil - }) - ctx := context.Background() - for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "claude_code"}} { - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { - t.Fatal(err) - } - } - other := <-h.gotSess - other.closeOutOnCancel = true - returned := make(chan error, 1) - go func() { - returned <- h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "slow", Input: proto.TextInput("extra")})) - }() - select { - case err := <-returned: - if err != nil { - t.Fatal(err) - } - case <-time.After(time.Second): - t.Fatal("steering blocked dispatch") - } - <-entered - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { - t.Fatal(err) - } - if other.cancels() != 1 { - t.Fatal("other run cancellation blocked") - } -} - -func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { - t.Helper() - frames := sender.snapshot() - if len(frames) == 0 { - t.Fatal("missing ack") - } - env := frames[len(frames)-1] - var ack proto.PromptSteerAckPayload - if env.Type != proto.TypePromptSteerAck || env.ID != runID { - t.Fatalf("incorrect routing: %+v", env) - } - if err := env.DecodePayload(&ack); err != nil { - t.Fatal(err) - } - if ack.InputID != inputID { - t.Fatalf("incorrect input: %+v", ack) - } - return ack -} - -func TestSteeringCapacityPreservesExistingReceipts(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - calls := 0 - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return &steeringSession{ - fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, - steer: func(context.Context, proto.PromptSteerPayload) error { - calls++ - return nil - }, - }, nil - }) - ctx := context.Background() - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{AgentKind: "codex"})); err != nil { - t.Fatal(err) - } - for i := range 257 { - input := proto.PromptSteerPayload{InputID: fmt.Sprintf("input-%d", i), Input: proto.TextInput("extra")} - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", input)); err != nil { - t.Fatal(err) - } - ack := lastSteeringAck(t, h.sender, "run-1", input.InputID) - if i < 256 && !ack.Accepted || i == 256 && ack.ErrorCode != "input_limit" { - t.Fatalf("input %d: %+v", i, ack) - } - } - if err := handleSteeringAndWait(t, h, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "input-0", Input: proto.TextInput("extra")})); err != nil { - t.Fatal(err) - } - if ack := lastSteeringAck(t, h.sender, "run-1", "input-0"); !ack.Accepted || calls != 256 { - t.Fatalf("receipt evicted or input redelivered: %+v, calls=%d", ack, calls) - } -} - -func handleSteeringAndWait(t *testing.T, h *harness, env proto.Envelope) error { - t.Helper() - before := len(h.sender.snapshot()) - if err := h.router.Handle(context.Background(), env); err != nil { - return err - } - waitFor(t, func() bool { return len(h.sender.snapshot()) > before }, "steering ack") - return nil -} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go deleted file mode 100644 index 9af962dd9..000000000 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package dispatch_test - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { - sender := &recSender{} - p := &controlledPreparation{closed: make(chan struct{})} - p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil - } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - for _, name := range []string{"file", "second"} { - if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { - t.Fatal(err) - } - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) - ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 1} - for _, phase := range []string{"idle", "active"} { - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) - result := waitWorkspaceRead(t, sender, phase) - if result.Outcome != "completed" || !result.CloseAcknowledged || result.Directory == nil || !result.Directory.Truncated || len(result.Directory.Entries) != 1 || len(result.Data) != 0 { - t.Fatal(result) - } - bad := request - bad.EnvironmentID = "another-environment" - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase+"-foreign", bad)) - if got := waitWorkspaceRead(t, sender, phase+"-foreign"); got.ErrorCode != "resource_unavailable" { - t.Fatal(got) - } - if phase == "idle" { - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("start")})) - waitPreparationStatus(t, sender, "prepare", "started", "") - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "stale", request)) - if got := waitWorkspaceRead(t, sender, "stale"); got.Outcome != "rejected" { - t.Fatal(got) - } - request.Handle, request.RunID = "", "run" - } - } - for index, bad := range []proto.WorkspaceReadPayload{ - {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2, MaxBytes: 1}, - {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: proto.WorkspaceDirectoryMaxEntries + 1}, - {Operation: "directory", Handle: ready.Handle, RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, - {Operation: "recursive", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, - } { - id := fmt.Sprintf("invalid-%d", index) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, id, bad)) - if got := waitWorkspaceRead(t, sender, id); got.Outcome != "rejected" || got.ErrorCode != "invalid_request" || got.Directory != nil { - t.Fatal("malformed directory control reached a resource", got) - } - } -} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read.go b/apps/parsar-daemon/internal/dispatch/workspace_read.go deleted file mode 100644 index 0932387d2..000000000 --- a/apps/parsar-daemon/internal/dispatch/workspace_read.go +++ /dev/null @@ -1,157 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "io/fs" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -const workspaceReadCapacity = 4 - -func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) error { - // Never echo an unbounded correlation ID onto the shared connection. - if len(env.ID) > proto.WorkspaceReadMaxIDBytes { - return errors.New("dispatch: invalid workspace read ID") - } - var request proto.WorkspaceReadPayload - if len(env.Payload) > proto.WorkspaceReadMaxRequestBytes || env.DecodePayload(&request) != nil || strings.TrimSpace(env.ID) == "" || - !proto.ValidWorkspaceReadRequest(request) { - return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("invalid_request")) - } - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return ErrRouterClosed - } - if _, exists := r.workspaceReads[env.ID]; exists { - r.mu.Unlock() - return errors.New("dispatch: workspace read already pending") - } - if len(r.workspaceReads) >= workspaceReadCapacity { - r.mu.Unlock() - return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead("read_capacity")) - } - resource, code := r.workspaceResourceLocked(request) - if code != "" { - r.mu.Unlock() - return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) - } - if r.workspaceReads == nil { - r.workspaceReads = make(map[string]struct{}) - } - r.workspaceReads[env.ID] = struct{}{} - r.shutdownWG.Add(1) - r.mu.Unlock() - go func() { - defer r.shutdownWG.Done() - defer func() { r.mu.Lock(); delete(r.workspaceReads, env.ID); r.mu.Unlock() }() - // 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) - _ = r.sendWorkspaceRead(context.WithoutCancel(ctx), env, result) - }() - return nil -} - -func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (any, string) { - var resource any - 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" - } - resource = p.prepared - 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 - } - if r.localWorkspace != nil { - resource = r.localWorkspace - } - return resource, "" -} - -func executeWorkspaceRead(ctx context.Context, resource any, 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) - 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) - } - 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") - } - read, err := reader.ReadWorkspaceFile(ctx, request.Path, request.MaxBytes) - return workspaceReadResult(read, err, request.MaxBytes) -} - -func rejectedWorkspaceRead(code string) proto.WorkspaceReadResultPayload { - return proto.WorkspaceReadResultPayload{Outcome: "rejected", ErrorCode: code} -} - -func workspaceReadResult(read agent.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} - } - for _, failure := range []struct { - err error - code string - }{ - {agent.ErrWorkspaceReadUnsupported, "read_unsupported"}, - {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, - {agent.ErrWorkspaceReadBusy, "read_capacity"}, - {agent.ErrWorkspaceReadInvalid, "invalid_request"}, - {agent.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, - {fs.ErrNotExist, "not_found"}, - {fs.ErrPermission, "permission_denied"}, - } { - if errors.Is(err, failure.err) { - return rejectedWorkspaceRead(failure.code) - } - } - return proto.WorkspaceReadResultPayload{Outcome: "unknown", ErrorCode: "read_unconfirmed"} -} - -func (r *Router) sendWorkspaceRead(ctx context.Context, request proto.Envelope, result proto.WorkspaceReadResultPayload) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - trace := request.Trace - if len(trace) > 256 { - trace = "" - } - env, err := proto.NewEnvelopeWithTrace(proto.TypeWorkspaceReadResult, request.ID, result, trace) - if err != nil { - return err - } - return r.sender.Send(ctx, env) -} diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go deleted file mode 100644 index 3e502c3d6..000000000 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ /dev/null @@ -1,204 +0,0 @@ -package localworkspace - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" -) - -// Prepare runs under the admitted executor's lifetime, before native startup. -// Reconnection validates installed contents without reopening mutable sources. -func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { - if b == nil && r.LocalEnvironment == nil || r.WorkspaceReadOnly { - return r, nil - } - if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.CapabilitySources == nil || - r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.WorkDir != b.workspace { - return r, agentcapabilities.ErrInvalid - } - b.capabilityMu.Lock() - defer b.capabilityMu.Unlock() - marker, err := b.openSnapshotMarker() - if err != nil { - return r, err - } - defer marker.close() - root, unlock, err := b.openCapabilities(ctx, marker.completed) - if err != nil { - return r, err - } - defer unlock() - input := *r.LocalEnvironment.CapabilitySources - identity := b.capabilityIdentity() - if err := prepareToolEnvironment(r.LocalEnvironment.ToolEnvironment, marker.completed); err != nil { - return r, err - } - manifest, err := b.loadCapabilitySnapshot(root, input, identity, marker.completed) - if err != nil || marker.complete() != nil { - return r, agentcapabilities.ErrInvalid - } - if err = ctx.Err(); err != nil { - return r, err - } - local := *r.LocalEnvironment - local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot - for i := range local.Skills { - local.Skills[i].InstallationRoot = b.capabilityRoot - } - if local.ToolEnvironment { - if _, err = ReadToolEnvironment(); err != nil { - return r, err - } - } - if len(manifest.MCP) != 0 { - if b.NetworkPolicy().Access != "enabled" { - return r, agentcapabilities.ErrInvalid - } - values, readErr := b.ReadToolEnvironment() - if readErr != nil { - return r, readErr - } - 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 - } - } - r.LocalEnvironment = &local - return r, nil -} - -// ApplyRuntimePreparation settles every filesystem operation before returning. A -// cancelled caller never leaves an unowned installation goroutine behind. -func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.RuntimePreparePayload, data []byte) error { - if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { - return agentcapabilities.ErrInvalid - } - b.capabilityMu.Lock() - defer b.capabilityMu.Unlock() - marker, err := b.openSnapshotMarker() - if err != nil { - return err - } - defer marker.close() - if marker.completed { - return agentcapabilities.ErrInvalid - } - if err := ctx.Err(); err != nil { - return err - } - switch input.Action { - case "file": - if len(data) != input.SizeBytes { - return agentcapabilities.ErrInvalid - } - return b.installInitialFile(ctx, *input.File, data) - case "initialize": - if len(data) != 0 { - return agentcapabilities.ErrInvalid - } - return b.initializeRuntime(ctx, *input.Initialization) - } - root, unlock, err := b.openCapabilities(ctx, false) - if err != nil { - return err - } - defer unlock() - switch input.Action { - case "skill": - err = agentcapabilities.InstallSkill(root, data, *input.Skill) - case "plugin": - err = agentcapabilities.InstallPlugin(root, input.Slot, data, *input.Plugin) - case "finalize": - if err := prepareToolEnvironment(false, false); err != nil { - return err - } - _, err = b.loadCapabilitySnapshot(root, *input.Sources, b.capabilityIdentity(), false) - if err == nil { - err = marker.complete() - } - default: - err = agentcapabilities.ErrInvalid - } - if err != nil { - return agentcapabilities.ErrInvalid - } - return ctx.Err() -} - -func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.Input, 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 - } - } else if err != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - manifest, err := agentcapabilities.Load(root) - if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - return manifest, nil -} - -func (b *Binding) capabilityIdentity() agentcapabilities.Identity { - return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: strings.TrimPrefix(b.stateKey, "agents-api-")} -} - -// The current Linux Runtime layout is shared by user-owned and managed hosts. -// Deployment paths never come from a capability transport request. -func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Root, func(), error) { - if err := ctx.Err(); err != nil { - return nil, nil, err - } - if b.capabilityRoot == "" || runtimefs.ValidateLocalPath(b.capabilityRoot) != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - if !completed { - if err := os.MkdirAll(b.capabilityRoot, 0700); err != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - } - root, err := os.OpenRoot(b.capabilityRoot) - if err != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - unlock, err := runtimefs.LockDirectory(root) - if err != nil { - root.Close() - return nil, nil, agentcapabilities.ErrInvalid - } - return root, func() { unlock(); root.Close() }, nil -} - -func containsPath(parent, child string) bool { - relative, err := filepath.Rel(parent, child) - return err == nil && (relative == "." || filepath.IsLocal(relative)) -} - -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")) - } - // Refuse recursive installation into the selected source itself. - if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { - return nil, agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot(source) - if err != nil { - return nil, agentcapabilities.ErrInvalid - } - return root, nil -} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go deleted file mode 100644 index 38040e2d5..000000000 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ /dev/null @@ -1,47 +0,0 @@ -package localworkspace - -import ( - "errors" - "os" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// MCPStdioCommand resolves the common installed manifest in the daemon. -func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { - executable, err := os.Executable() - if err != nil { - return "", nil - } - return executable, []string{"runtime-mcp-exec", server.InstallationRoot, server.PackageRoot, server.Server.Name} -} - -func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { - result := make([]proto.EnvironmentMCP, 0, len(installed)) - names := map[string]bool{} - for _, item := range installed { - server := item.Server - if names[server.Name] { - return nil, errors.New("ambiguous environment MCP server identity") - } - names[server.Name] = true - resolved := proto.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: server} - variables := append([]string{}, server.EnvVars...) - if server.BearerTokenEnvVar != "" { - variables = append(variables, server.BearerTokenEnvVar) - } - for _, name := range variables { - value, exists := values[name] - if !exists || strings.ContainsRune(value, 0) { - return nil, errors.New("declared environment MCP variable unavailable") - } - if name == server.BearerTokenEnvVar { - resolved.BearerToken = &value - } - } - result = append(result, resolved) - } - return result, nil -} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go deleted file mode 100644 index 77293a5e0..000000000 --- a/apps/parsar-daemon/internal/localworkspace/mcp_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package localworkspace - -import ( - "os" - "slices" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" -) - -func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { - t.Setenv("PLUGIN_TOKEN", "native-private-value") - installed := []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "remote", Type: "http", URL: "https://example.com/mcp", BearerTokenEnvVar: "PLUGIN_TOKEN", - }}} - if _, err := resolveEnvironmentMCP(installed, nil); err == nil { - t.Fatal("native credential became a user Plugin credential") - } - servers, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}) - if err != nil || len(servers) != 1 || servers[0].BearerToken == nil || *servers[0].BearerToken != "user-token" { - t.Fatal("initialized credential was not selected", err) - } - installed = append(installed, agentcapabilities.InstalledMCP{PackageRoot: "plugins/1", Server: installed[0].Server}) - if _, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}); err == nil { - t.Fatal("ambiguous server identity accepted") - } -} - -func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { - server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, - }} - command, args := MCPStdioCommand(server) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - if command != executable || !slices.Equal(args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "package_tool"}) { - t.Fatal("native configuration included untrusted process configuration") - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go deleted file mode 100644 index b71ed6b97..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go +++ /dev/null @@ -1,225 +0,0 @@ -package localworkspace - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" -) - -// InitializationFailure contains only a confirmed step's safe exit status. -type InitializationFailure struct{ ExitCode *int } - -func (*InitializationFailure) Error() string { return "Runtime initialization failed" } - -var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") - -func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { - raw, err := json.Marshal(input) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - if ctx.Err() != nil { - return ErrInitializationUnconfirmed - } - if input.Action == "configure" { - return configureRuntime(input.Env) - } - if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { - return agentcapabilities.ErrInvalid - } - if input.Network != "enabled" && input.Network != "disabled" { - return agentcapabilities.ErrInvalid - } - directory, err := b.initializationCWD(input.CWD) - if err != nil { - return err - } - values, err := ReadToolEnvironment() - if err != nil { - return &InitializationFailure{} - } - packages, err := PackageDirectory() - if err != nil { - return &InitializationFailure{} - } - var binary string - var args []string - switch input.Action { - case "setup": - if input.Command == "" || strings.ContainsRune(input.Command, 0) { - return agentcapabilities.ErrInvalid - } - binary, err = initializationBash() - args = []string{"--noprofile", "--norc", "-c", input.Command} - case "npm", "python": - if len(input.Packages) == 0 { - return agentcapabilities.ErrInvalid - } - for _, value := range input.Packages { - if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { - return agentcapabilities.ErrInvalid - } - } - if err = os.MkdirAll(packages, 0700); err != nil { - return &InitializationFailure{} - } - if input.Action == "npm" { - binary, args, err = initializationNPM() - args = append(args, "install", "--global", "--prefix", filepath.Join(packages, "npm"), "--") - } else { - binary, err = initializationPython() - args = []string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", filepath.Join(packages, "python"), "--"} - } - args = append(args, input.Packages...) - } - if err != nil { - return err - } - return runInitializationProcess(ctx, binary, args, directory, initializationEnvironment(values)) -} - -func (b *Binding) initializationCWD(value string) (string, error) { - if value == "" || value == "/workspace" { - return b.workspace, nil - } - if !strings.HasPrefix(value, "/workspace/") { - return "", agentcapabilities.ErrInvalid - } - relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) - if err != nil { - return "", agentcapabilities.ErrInvalid - } - return filepath.Join(b.workspace, relative), nil -} - -func configureRuntime(values map[string]string) error { - if !validToolEnvironment(values) { - return agentcapabilities.ErrInvalid - } - path, err := initializedToolEnvironmentPath() - if err != nil { - return &InitializationFailure{} - } - packages, err := PackageDirectory() - if err != nil { - return &InitializationFailure{} - } - if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { - return &InitializationFailure{} - } - root, err := os.OpenRoot(filepath.Dir(path)) - if err != nil { - return &InitializationFailure{} - } - defer root.Close() - unlock, err := runtimefs.LockDirectory(root) - if err != nil { - return &InitializationFailure{} - } - defer unlock() - if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { - return &InitializationFailure{} - } - configured := make(map[string]string, len(values)+2) - if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { - if runtimefs.ValidateLocalPath(source) != nil { - return &InitializationFailure{} - } - local, err := readToolEnvironmentFile(source) - if err != nil { - return &InitializationFailure{} - } - for key, value := range local { - if runtime.GOOS == "windows" { - key = strings.ToUpper(key) - } - configured[key] = value - } - } - // Explicit Session values override the operator's base tool configuration. - for key, value := range values { - if runtime.GOOS == "windows" { - key = strings.ToUpper(key) - } - configured[key] = value - } - separator := string(os.PathListSeparator) - npmBin := filepath.Join(packages, "npm", "bin") - pythonBin := filepath.Join(packages, "python", "bin") - if runtime.GOOS == "windows" { - npmBin = filepath.Join(packages, "npm") - pythonBin = filepath.Join(packages, "python", "Scripts") - } - pathValue, exists := configured["PATH"] - if !exists { - pathValue = os.Getenv("PATH") - } - configured["PATH"] = npmBin + separator + pythonBin + separator + pathValue - pythonPath := configured["PYTHONPATH"] - configured["PYTHONPATH"] = filepath.Join(packages, "python") - if pythonPath != "" { - configured["PYTHONPATH"] += separator + pythonPath - } - raw, err := json.Marshal(configured) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - if runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -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 { - return agentcapabilities.ErrInvalid - } - relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) - if err != nil { - return agentcapabilities.ErrInvalid - } - if ctx.Err() != nil { - return ErrInitializationUnconfirmed - } - w := b.writer - if !w.mu.TryLock() { - return agentcapabilities.ErrInvalid - } - defer w.mu.Unlock() - if w.uncertain { - return ErrInitializationUnconfirmed - } - defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() - root, err := os.OpenRoot(b.workspace) - if err != nil { - return &InitializationFailure{} - } - defer root.Close() - if root.MkdirAll(filepath.Dir(relative), 0700) != nil { - return &InitializationFailure{} - } - parent, err := root.OpenRoot(filepath.Dir(relative)) - if err != nil { - return &InitializationFailure{} - } - defer parent.Close() - // Initial files replace existing contents, unlike public Files create. Finish - // the synchronous atomic write before returning even if cancellation arrives. - if runtimefs.WritePrivateAtomic(parent, filepath.Base(relative), data) != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -func initializationDependency(name string) error { - return fmt.Errorf("Runtime initialization requires %s: %w", name, &InitializationFailure{}) -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go deleted file mode 100644 index 916634410..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// The test executable doubles as a native package-manager fixture on all OSes. -// Dispatch before testing parses npm/pip arguments; no dependencies are fetched. -func init() { - if os.Getenv("OAC_INITIALIZATION_PACKAGE_FIXTURE") != "1" { - return - } - raw, _ := json.Marshal(os.Args[1:]) - if os.WriteFile(os.Getenv("OAC_INITIALIZATION_PACKAGE_RECEIPT"), raw, 0600) != nil { - os.Exit(9) - } - os.Exit(0) -} - -func TestRuntimeInitializationPackageTargets(t *testing.T) { - b := initializationFixture(t) - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - source, err := os.ReadFile(binary) - if err != nil { - t.Fatal(err) - } - bin := t.TempDir() - suffix := "" - if runtime.GOOS == "windows" { - suffix = ".exe" - } - for _, name := range []string{"node", "npm", "python3"} { - if name == "npm" && runtime.GOOS == "windows" { - if err = os.WriteFile(filepath.Join(bin, "npm.cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { - t.Fatal(err) - } - continue - } - if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { - t.Fatal(err) - } - } - if runtime.GOOS == "windows" { - cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") - if err = os.MkdirAll(filepath.Dir(cli), 0700); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(cli, nil, 0600); err != nil { - t.Fatal(err) - } - } - t.Setenv("PATH", bin) - receipt := filepath.Join(t.TempDir(), "args.json") - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OAC_INITIALIZATION_PACKAGE_FIXTURE": "1", "OAC_INITIALIZATION_PACKAGE_RECEIPT": receipt}}); err != nil { - t.Fatal(err) - } - packages, _ := PackageDirectory() - for _, action := range []string{"npm", "python"} { - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { - t.Fatal(action, err) - } - raw, err := os.ReadFile(receipt) - if err != nil { - t.Fatal(err) - } - var args []string - if json.Unmarshal(raw, &args) != nil { - t.Fatal("invalid fixture receipt") - } - if action == "npm" && runtime.GOOS == "windows" { - cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") - if len(args) == 0 || args[0] != cli { - t.Fatal("initializer bypassed the shared npm shim resolver", args) - } - } - flag, target := "--prefix", filepath.Join(packages, "npm") - if action == "python" { - flag, target = "--target", filepath.Join(packages, "python") - } - found := false - for i := 0; i+1 < len(args); i++ { - if args[i] == flag && args[i+1] == target { - found = true - } - } - if !found || len(args) < 3 || args[len(args)-3] != "--" || args[len(args)-2] != "name with spaces" || args[len(args)-1] != "second" { - t.Fatal("package argv or local target mismatch", args) - } - } -} - -func TestRuntimeInitializationChild(t *testing.T) { - mode := os.Getenv("OAC_INITIALIZATION_FIXTURE") - if mode == "" { - return - } - directory := os.Getenv("OAC_INITIALIZATION_FIXTURE_DIR") - switch mode { - case "complete": - if os.Getenv("RUNTIME_TEST_PRIVATE") != "" { - os.Exit(9) - } - os.Stdout.Write(bytes.Repeat([]byte("private-output"), 10000)) - os.Stderr.Write(bytes.Repeat([]byte("private-error"), 10000)) - case "fail": - os.Exit(7) - case "child": - time.Sleep(time.Second) - _ = os.WriteFile(filepath.Join(directory, "late"), []byte("bad"), 0600) - case "parent": - binary, _ := os.Executable() - child := exec.Command(binary, "-test.run=^TestRuntimeInitializationChild$") - child.Env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=child", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) - child.Stdout, child.Stderr = os.Stdout, os.Stderr - if child.Start() != nil { - os.Exit(8) - } - _ = os.WriteFile(filepath.Join(directory, "started"), []byte("ready"), 0600) - _ = child.Wait() - } - os.Exit(0) -} - -func initializationFixture(t *testing.T) *Binding { - t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "") - t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", "") - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") - return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} -} - -func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { - b := initializationFixture(t) - config, err := InitializationDirectory() - if err != nil || config != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "initialization") { - t.Fatal(config, err) - } - packages, err := PackageDirectory() - if err != nil || packages != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "packages") { - t.Fatal(packages, err) - } - env := map[string]string{"VALUE": "'\n$(not-a-command)", "PYTHONPATH": "operator-path"} - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: env}); err != nil { - t.Fatal(err) - } - values, err := ReadToolEnvironment() - if err != nil || values["VALUE"] != env["VALUE"] || !strings.HasPrefix(values["PYTHONPATH"], filepath.Join(packages, "python")) { - t.Fatal("configuration mismatch", err) - } - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "changed"}}) == nil { - t.Fatal("configuration replaced") - } - values, _ = ReadToolEnvironment() - if values["VALUE"] != env["VALUE"] { - t.Fatal("configuration changed") - } - for setting, resolver := range map[string]func() (string, error){"OAC_RUNTIME_INITIALIZATION_DIRECTORY": InitializationDirectory, "OAC_RUNTIME_PACKAGE_DIRECTORY": PackageDirectory} { - selected := t.TempDir() - t.Setenv(setting, selected) - if actual, err := resolver(); err != nil || actual != selected { - t.Fatal("operator directory ignored", err) - } - } -} - -func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { - b := initializationFixture(t) - file := filepath.Join(t.TempDir(), "explicit.json") - original := []byte(`{"DECLARED":"value","OVERRIDE":"local"}`) - if err := os.WriteFile(file, original, 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - env, err := ReadOptionalToolEnvironment() - if err != nil || env["DECLARED"] != "value" { - t.Fatal("explicit tool environment", err) - } - if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OVERRIDE": "session"}}); err != nil { - t.Fatal(err) - } - if raw, err := os.ReadFile(file); err != nil || string(raw) != string(original) { - t.Fatal("operator source was modified", err) - } - // Reconnect reads the frozen result even after the operator edits its source. - if err := os.WriteFile(file, []byte(`{"DECLARED":"changed"}`), 0600); err != nil { - t.Fatal(err) - } - env, err = ReadOptionalToolEnvironment() - if err != nil || env["DECLARED"] != "value" || env["OVERRIDE"] != "session" { - t.Fatal("tool snapshot was not frozen", err) - } - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { - t.Fatal("configuration replay succeeded") - } -} - -func TestRuntimeInitializationRejectsMissingExplicitToolEnvironment(t *testing.T) { - b := initializationFixture(t) - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { - t.Fatal("missing explicit environment was ignored") - } - path, err := initializedToolEnvironmentPath() - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("failed configuration left a prepared snapshot") - } -} - -func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { - b := initializationFixture(t) - if _, err := initializationBash(); err != nil { - t.Skip("Bash unavailable; native dependency failure is tested separately") - } - if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "configured"}}); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(b.workspace, "sub"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { - t.Fatal(err) - } - err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) - if err != nil { - t.Fatal(err) - } - for name, want := range map[string]string{"proof": "skill-proof", "value": "configured"} { - raw, err := os.ReadFile(filepath.Join(b.workspace, "sub", name)) - if err != nil || string(raw) != want { - t.Fatal(name, err) - } - } -} - -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) - } - for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { - var failed *InitializationFailure - if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { - t.Fatal("missing dependency was not explicit", input.Action, err) - } - } - for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { - if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { - t.Fatal("invalid request accepted", input.Action) - } - } -} - -func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { - t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=complete") - if err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env); err != nil { - t.Fatal(err) - } - env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") - err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) - var failed *InitializationFailure - if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { - t.Fatal("exit status", err) - } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) - done := make(chan error, 1) - go func() { - done <- runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) - }() - deadline := time.Now().Add(5 * time.Second) - for { - if _, err = os.Stat(filepath.Join(directory, "started")); err == nil { - break - } - if time.Now().After(deadline) { - cancel() - <-done - t.Fatal("fixture startup timeout") - } - time.Sleep(5 * time.Millisecond) - } - cancel() - if err = <-done; !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("cancel should be unknown", err) - } - time.Sleep(1100 * time.Millisecond) - if _, err = os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { - t.Fatal("descendant survived initialization") - } -} - -func TestRuntimeInitialFileAtomicReplacement(t *testing.T) { - b := initializationFixture(t) - path := "/workspace/nested/child/file" - for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { - t.Fatal(err) - } - actual, err := os.ReadFile(filepath.Join(b.workspace, "nested", "child", "file")) - if err != nil || !bytes.Equal(actual, body) { - t.Fatal("atomic replacement", err) - } - } - for _, invalid := range []string{"/elsewhere/file", "/workspace/../outside", "/workspace/a\\b"} { - if !errors.Is(b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: invalid}, []byte("x")), agentcapabilities.ErrInvalid) { - t.Fatal("invalid path accepted") - } - } -} -func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { - b, req := testBinding(t) - configured, err := b.Configure(req) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), configured); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(nil) - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted file", err) - } - input.Action = "initialize" - input.File = nil - input.SHA256 = "" - input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted initialize", err) - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go deleted file mode 100644 index 5a9a90ca5..000000000 --- a/apps/parsar-daemon/internal/localworkspace/skills.go +++ /dev/null @@ -1,13 +0,0 @@ -package localworkspace - -import ( - "path/filepath" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" -) - -const CapabilityDirectory = agentcapabilities.Directory - -func SkillPath(skill agentcapabilities.InstalledSkill) string { - return filepath.Join(skill.InstallationRoot, skill.RelativeRoot) -} diff --git a/apps/parsar-daemon/internal/localworkspace/skills_test.go b/apps/parsar-daemon/internal/localworkspace/skills_test.go deleted file mode 100644 index a4038ea1c..000000000 --- a/apps/parsar-daemon/internal/localworkspace/skills_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package localworkspace - -import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "path/filepath" - "testing" -) - -func TestSkillPathUsesRuntimeInstallationRoot(t *testing.T) { - root := t.TempDir() - skill := agentcapabilities.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/parsar-daemon/internal/transport/bootstrap_test.go b/apps/parsar-daemon/internal/transport/bootstrap_test.go deleted file mode 100644 index e755a988b..000000000 --- a/apps/parsar-daemon/internal/transport/bootstrap_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package transport_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" -) - -func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { - var sawAuth, sawCT, sawDeviceID string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/agent-daemon/bootstrap" { - t.Errorf("unexpected path %q", r.URL.Path) - } - if r.Method != http.MethodPost { - t.Errorf("unexpected method %q", r.Method) - } - sawAuth = r.Header.Get("Authorization") - sawCT = r.Header.Get("Content-Type") - var body struct { - DeviceID string `json:"device_id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - sawDeviceID = body.DeviceID - 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() - - resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt_abc", "secret123", "0.0.0-dev") - if err != nil { - t.Fatalf("Bootstrap: %v", err) - } - if resp.DeviceID != "rt_abc" || resp.WorkspaceID != "ws_xyz" { - t.Errorf("unexpected response: %+v", resp) - } - if resp.WSURL != "wss://example/agent-daemon/ws" { - t.Errorf("ws_url = %q", resp.WSURL) - } - if got := resp.HeartbeatInterval().Seconds(); got != 15 { - t.Errorf("HeartbeatInterval = %vs, want 15s", got) - } - if sawAuth != "Bearer secret123" { - t.Errorf("Authorization = %q, want Bearer secret123", sawAuth) - } - if sawCT != "application/json" { - t.Errorf("Content-Type = %q, want application/json", sawCT) - } - if sawDeviceID != "rt_abc" { - t.Errorf("body.device_id = %q, want rt_abc", sawDeviceID) - } -} - -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 - }) - })) - defer srv.Close() - - resp, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") - if err != nil { - t.Fatalf("Bootstrap: %v", err) - } - if got := resp.HeartbeatInterval().Seconds(); got != 15 { - t.Errorf("HeartbeatInterval = %vs, want 15s default", got) - } -} - -func TestBootstrapNonSuccessSurfacesBody(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":"bad_credential","detail":"wrong key"}`)) - })) - defer srv.Close() - - _, err := transport.Bootstrap(context.Background(), srv.URL, "rt", "c", "v") - if err == nil { - t.Fatal("Bootstrap returned nil error on 401") - } - if !strings.Contains(err.Error(), "bad_credential") || !strings.Contains(err.Error(), "401") { - t.Errorf("error %q missing 'bad_credential' or '401'", err.Error()) - } -} - -func TestBootstrapRejectsEmptyInputs(t *testing.T) { - cases := []struct{ name, base, device, cred string }{ - {"empty base", "", "rt", "c"}, - {"empty device", "https://x", "", "c"}, - {"empty cred", "https://x", "rt", ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := transport.Bootstrap(context.Background(), tc.base, tc.device, tc.cred, "v") - if err == nil { - t.Fatal("Bootstrap accepted empty input") - } - }) - } -} - -func TestDeriveWSURLPrefersAbsolute(t *testing.T) { - got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") - if err != nil { - t.Fatalf("DeriveWSURL: %v", err) - } - if got != "wss://prod/agent-daemon/ws" { - t.Errorf("got %q, want wss://prod/agent-daemon/ws", got) - } -} - -func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { - cases := []struct{ base, want string }{ - {"https://core.example.com", "wss://core.example.com/agent-daemon/ws"}, - {"http://localhost:3000", "ws://localhost:3000/agent-daemon/ws"}, - {"http://localhost:3000/", "ws://localhost:3000/agent-daemon/ws"}, - {"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) - if err != nil { - t.Errorf("DeriveWSURL(%q): %v", tc.base, err) - continue - } - if got != tc.want { - t.Errorf("DeriveWSURL(%q) = %q, want %q", tc.base, got, tc.want) - } - } -} - -func TestDeriveWSURLRejectsRelativeWSURL(t *testing.T) { - _, err := transport.DeriveWSURL(transport.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") - if err == nil { - t.Fatal("DeriveWSURL accepted ftp scheme") - } -} diff --git a/apps/parsar-daemon/testdata/onboarding/main.go b/apps/parsar-daemon/testdata/onboarding/main.go deleted file mode 100644 index 4d46c107e..000000000 --- a/apps/parsar-daemon/testdata/onboarding/main.go +++ /dev/null @@ -1,198 +0,0 @@ -// This synthetic harness exercises the production router without a native model. -// It is test-only, has no workspace/tools, and is never in the built-in catalog. -package main - -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "os" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" -) - -type sender struct { - mu sync.Mutex - encoder *json.Encoder -} - -func (s *sender) Send(_ context.Context, e proto.Envelope) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.encoder.Encode(e) -} - -type harness struct { - mu sync.Mutex - history map[string]string -} - -func (h *harness) prepare(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, errors.New("preparation submitted fixture input") - } - if !req.StrictResume || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { - return nil, errors.New("unsupported fixture operation") - } - h.mu.Lock() - defer h.mu.Unlock() - previous := h.history[req.AgentStateKey] - if req.AgentSessionID != previous || (req.RequireExistingNativeSession && previous == "") { - return nil, errors.New("native history mismatch") - } - if previous == "" { - previous = "fixture-" + req.AgentStateKey - h.history[req.AgentStateKey] = previous - } - return &executor{native: previous}, nil -} - -type executor struct { - mu sync.Mutex - native string - active *session - closed bool -} - -func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { - if ctx.Err() != nil || run == "" || out == nil { - return nil, errors.New("invalid fixture Start") - } - if _, err := input.TextOnly(); err != nil { - return nil, err - } - e.mu.Lock() - defer e.mu.Unlock() - if e.closed || e.active != nil { - return nil, errors.New("fixture Executor unavailable") - } - s := &session{out: out, run: run, native: e.native, settled: make(chan struct{})} - s.release = func() { - e.mu.Lock() - if e.active == s { - e.active = nil - } - e.mu.Unlock() - } - e.active = s - s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: "ready", Sequence: 1}) - return s, nil -} - -func (e *executor) Close(ctx context.Context) error { - e.mu.Lock() - e.closed = true - current := e.active - e.mu.Unlock() - if current != nil { - return current.Cancel(ctx) - } - return nil -} - -type session struct { - mu sync.Mutex - out chan<- proto.Envelope - run, native string - closed bool - settled chan struct{} - release func() -} - -func (s *session) emit(kind string, payload any) { - e, _ := proto.NewEnvelope(kind, s.run, payload) - s.out <- e -} -func (s *session) Cancel(context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - if !s.closed { - s.closed = true - close(s.out) - s.release() - close(s.settled) - } - return nil -} -func (s *session) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: "cancelled", Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} -} -func (s *session) Steer(ctx context.Context, p proto.PromptSteerPayload) error { - return s.SteerWithReceipt(ctx, p, func() {}) -} -func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload, written func()) error { - text, err := p.Input.TextOnly() - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.closed { - return agent.ErrSteeringInactive - } - written() - s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: 2}) - s.emit(proto.TypeDone, proto.DonePayload{Content: "ready" + text, Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) - s.closed = true - close(s.out) - s.release() - close(s.settled) - return nil -} - -func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { - select { - case <-s.settled: - return agent.TurnSettlement{Reusable: true}, nil - case <-ctx.Done(): - return agent.TurnSettlement{}, ctx.Err() - } -} - -func run() error { - registry := agent.NewRegistry() - h := &harness{history: map[string]string{}} - registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, - })}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("fixture execution requires an Executor") - }) - registry.RegisterExecutor("fixture_harness", h.prepare) - sink := &sender{encoder: json.NewEncoder(os.Stdout)} - router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) - if err != nil { - return err - } - defer router.Shutdown(context.Background()) - heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: registry.SupportedAgentKinds()}) - if err = sink.Send(context.Background(), heartbeat); err != nil { - return err - } - decoder := json.NewDecoder(os.Stdin) - for { - var e proto.Envelope - if err = decoder.Decode(&e); errors.Is(err, io.EOF) { - return nil - } else if err != nil { - return err - } - if err = router.Handle(context.Background(), e); err != nil { - return err - } - } -} -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 1d17230e8..4cf436bf1 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -22,7 +22,7 @@ The console runs beside the administrator's own Core, with execution, files and ## Operating Context -- Paired console (`services/core-console`): the administrator signs in with the deployment's Core key, the administration credential the installer writes to `secrets/core.key` under the installation directory (by default `~/.oac/core/secrets/core.key`; keeping and rotating it is described in [Core key](../../docs/getting-started/operations.md#core-key)). There are no console accounts or usernames. Sign-in shows the default file location and a copyable `cat ~/.oac/core/secrets/core.key` command for the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. +- Paired console (`services/web`): the administrator signs in with the deployment's Core key, the administration credential the installer writes to `secrets/core.key` under the installation directory (by default `~/.oac/core/secrets/core.key`; keeping and rotating it is described in [Core key](../../docs/getting-started/operations.md#core-key)). There are no console accounts or usernames. Sign-in shows the default file location and a copyable `cat ~/.oac/core/secrets/core.key` command for the Core host, with a reminder to substitute a custom installation directory. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards the Web API (`/core/v1/**`, including sandbox administration under `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports the node installer (`node_installer`, `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, sandbox administration included. - Chinese and English UI; light and dark themes; reduced motion honored. diff --git a/apps/web/README.md b/apps/web/README.md index e91129cef..7db38237d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,6 +1,6 @@ # Web console package -`apps/web` (`@agents-core-web/web`) is the React application of the OpenAgentCore administrator console. The [console server](../../docs/web/console-server.md) serves its production build. [DESIGN.md](DESIGN.md) records the visual system and [PRODUCT.md](PRODUCT.md) the product scope and behavior; the [operator guide](../../docs/web/README.md) describes the console for administrators. +`apps/web` (`@oac/web`) is the React application of the OpenAgentCore administrator console. The [console server](../../docs/web/console-server.md) serves its production build. [DESIGN.md](DESIGN.md) records the visual system and [PRODUCT.md](PRODUCT.md) the product scope and behavior; the [operator guide](../../docs/web/README.md) describes the console for administrators. ## Rules for console code @@ -30,13 +30,13 @@ Open `http://127.0.0.1:4173` and sign in with the fixture-only key `fixture-core From the repository root: ```sh -pnpm --filter @agents-core-web/web typecheck -pnpm --filter @agents-core-web/web test -pnpm --filter @agents-core-web/web build +pnpm --filter @oac/web typecheck +pnpm --filter @oac/web test +pnpm --filter @oac/web build pnpm test:web:acceptance ``` -`pnpm test:web:acceptance` runs the Playwright tests in `apps/web/e2e` in Chrome against the fixture console. After each test, every spec except `domain.spec.ts` checks that the browser sent nothing to `/v1` and no `Authorization` header. The tests do not exercise `services/core-console` or a real Core; the console server has its own Go tests. `make check-web` runs all of these; [CONTRIBUTING.md](../../CONTRIBUTING.md) lists the repository's required checks. +`pnpm test:web:acceptance` runs the Playwright tests in `apps/web/e2e` in Chrome against the fixture console. After each test, every spec except `domain.spec.ts` checks that the browser sent nothing to `/v1` and no `Authorization` header. The tests do not exercise `services/web` or a real Core; the console server has its own Go tests. `make check-web` runs all of these; [CONTRIBUTING.md](../../CONTRIBUTING.md) lists the repository's required checks. ## README screenshots @@ -47,7 +47,7 @@ OAC_WEB_SCREENSHOT_DEMO=1 AGENTS_FIXTURE_PORT=18394 node apps/web/e2e/fixture-co ``` ```sh -OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:18394 pnpm --filter @agents-core-web/web exec vite --host 127.0.0.1 --mode test --port 4394 +OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:18394 pnpm --filter @oac/web exec vite --host 127.0.0.1 --mode test --port 4394 ``` Open `http://127.0.0.1:4394` in Chrome and sign in with the fixture key `fixture-core-key-3f9a2c71`. Capture Overview and Agent metrics in light mode, once in English and once in Chinese using the console language menu. Check that all five nodes load and metrics have no partial-data warning before capturing. For a remote preview, forward port 4394 over SSH and capture in local Chrome. Keep the original resolution, crop browser chrome and add a plain macOS-style window bar. Save the four images as `docs/assets/console-*.webp`; the READMEs link them and the distribution manifest includes them. Normal acceptance data and production builds do not enable this scene. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 4673fcfaa..5546bb99b 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment, SandboxNodeRollout } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxNodeRollout } from "@oac/agents-client"; import { expect, type APIRequestContext, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b546dcff0..eff4e675b 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -143,7 +143,7 @@ function send(response, status, body, headers = {}) { response.end(JSON.stringify(body)); } function error(response, status, message, code = null) { - // Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go). + // Derive `type` as Core's writeError does (services/core/internal/api/errors.go). const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; send(response, status, { error: { message, type, code, param: null } }); @@ -538,7 +538,7 @@ function providerProblem(harness, input) { /** * Harnesses and their deployment default model providers, as Core serves them - * (services/agents-api/internal/api/harness_model_providers.go): the list + * (services/core/internal/api/harness_model_providers.go): the list * reflects every write; an unknown harness or an unset provider is 404; PUT * takes a JSON object of at most 32 KiB, is a full replacement that needs the * key every time (mcode also both limits) and answers with the safe view; diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index 7b6548de8..11d5f0b5c 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator, type Page, type TestInfo } from "@playwright/test"; -import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@oac/agents-client"; import { expectManagementBoundary, failNext, openConsole, setDeployment, setNode, writes } from "./console"; diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index 168072bfb..4cda22fc3 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test"; -import type { SandboxReset } from "@agents-core-web/agents-client"; +import type { SandboxReset } from "@oac/agents-client"; import { expectManagementBoundary, FIXTURE_CORE_KEY, openConsole, setDeployment, writes } from "./console"; diff --git a/apps/web/package.json b/apps/web/package.json index fe3189283..9b640bb7f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "@agents-core-web/web", + "name": "@oac/web", "version": "0.1.0", "private": true, "type": "module", @@ -10,7 +10,7 @@ "test": "vitest run" }, "dependencies": { - "@agents-core-web/agents-client": "workspace:*", + "@oac/agents-client": "workspace:*", "@base-ui/react": "^1.8.0", "@fontsource-variable/geist-mono": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 4d10807fc..1f90001d7 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -1,4 +1,4 @@ -import type { CoreInstallation } from "@agents-core-web/agents-client"; +import type { CoreInstallation } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { InstallationNotice } from "./InstallationNotice"; diff --git a/apps/web/src/components/InstallationNotice.tsx b/apps/web/src/components/InstallationNotice.tsx index 84fefbffa..25342fe20 100644 --- a/apps/web/src/components/InstallationNotice.tsx +++ b/apps/web/src/components/InstallationNotice.tsx @@ -1,4 +1,4 @@ -import type { CoreInstallation } from "@agents-core-web/agents-client"; +import type { CoreInstallation } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { useConsoleNavigation } from "../lib/console-navigation"; import "./installation-notice.css"; diff --git a/apps/web/src/components/console-ui.test.tsx b/apps/web/src/components/console-ui.test.tsx index ddb72fc86..b0ff60ac1 100644 --- a/apps/web/src/components/console-ui.test.tsx +++ b/apps/web/src/components/console-ui.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { AgentSession, RuntimeObservation } from "@agents-core-web/agents-client"; +import type { AgentSession, RuntimeObservation } from "@oac/agents-client"; import { HelpTip, Kpi, KpiStrip, PageHeader } from "./console-ui"; diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index 36496e2be..e3f9fab0e 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -1,4 +1,4 @@ -import type { SavedAgent } from "@agents-core-web/agents-client"; +import type { SavedAgent } from "@oac/agents-client"; import { ArrowLeft, Bot, ListTree, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index e68dd3e1c..df1cedf90 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { flowError, keyFlowReducer, type KeyFlow } from "./key-flows"; import { KeyFlowDialogs, PendingKeyNotice } from "./KeyFlowDialogs"; import type { KeyFlowControls } from "./use-key-flow"; diff --git a/apps/web/src/features/api-keys/key-flows.test.ts b/apps/web/src/features/api-keys/key-flows.test.ts index e07f449db..1c167af1e 100644 --- a/apps/web/src/features/api-keys/key-flows.test.ts +++ b/apps/web/src/features/api-keys/key-flows.test.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import { type AdminIssuedKey, type AdminKey, type Project } from "../../lib/admin-view"; diff --git a/apps/web/src/features/api-keys/key-flows.ts b/apps/web/src/features/api-keys/key-flows.ts index 8205c06f6..b960bad53 100644 --- a/apps/web/src/features/api-keys/key-flows.ts +++ b/apps/web/src/features/api-keys/key-flows.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { type AdminIssuedKey, type AdminKey, type Project } from "../../lib/admin-view"; /** diff --git a/apps/web/src/features/dashboard/dashboard-model.ts b/apps/web/src/features/dashboard/dashboard-model.ts index 9ed99f5c5..2afac2308 100644 --- a/apps/web/src/features/dashboard/dashboard-model.ts +++ b/apps/web/src/features/dashboard/dashboard-model.ts @@ -1,4 +1,4 @@ -import type { TokenUsage } from "@agents-core-web/agents-client"; +import type { TokenUsage } from "@oac/agents-client"; function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) diff --git a/apps/web/src/features/dashboard/runtime-history.test.ts b/apps/web/src/features/dashboard/runtime-history.test.ts index ed73d82b4..18c935421 100644 --- a/apps/web/src/features/dashboard/runtime-history.test.ts +++ b/apps/web/src/features/dashboard/runtime-history.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { AgentCoreError, type AgentSession, type RuntimeHistory, type RuntimeObservation } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type RuntimeHistory, type RuntimeObservation } from "@oac/agents-client"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 4e7560ec5..0f07a386b 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentSession, type CoreProjectReader, type RuntimeHistory } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader, type RuntimeHistory } from "@oac/agents-client"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { deriveTokenThroughput, type RuntimeTrendSample, type RuntimeTrendTarget } from "./runtime-trends"; diff --git a/apps/web/src/features/dashboard/runtime-snapshot.ts b/apps/web/src/features/dashboard/runtime-snapshot.ts index 04fd11c27..8006cebce 100644 --- a/apps/web/src/features/dashboard/runtime-snapshot.ts +++ b/apps/web/src/features/dashboard/runtime-snapshot.ts @@ -1,4 +1,4 @@ -import type { AgentSession, RuntimeObservation } from "@agents-core-web/agents-client"; +import type { AgentSession, RuntimeObservation } from "@oac/agents-client"; export const RUNTIME_SNAPSHOT_REFRESH_MS = 30_000; diff --git a/apps/web/src/features/dashboard/runtime-trends.test.ts b/apps/web/src/features/dashboard/runtime-trends.test.ts index ab6e339d8..5ec812f9f 100644 --- a/apps/web/src/features/dashboard/runtime-trends.test.ts +++ b/apps/web/src/features/dashboard/runtime-trends.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentSession, RuntimeObservation } from "@agents-core-web/agents-client"; +import type { AgentSession, RuntimeObservation } from "@oac/agents-client"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { diff --git a/apps/web/src/features/dashboard/runtime-trends.ts b/apps/web/src/features/dashboard/runtime-trends.ts index 342c90d10..e8500e760 100644 --- a/apps/web/src/features/dashboard/runtime-trends.ts +++ b/apps/web/src/features/dashboard/runtime-trends.ts @@ -1,4 +1,4 @@ -import type { AgentSession, RuntimeObservation } from "@agents-core-web/agents-client"; +import type { AgentSession, RuntimeObservation } from "@oac/agents-client"; import { holdLastReported } from "./held-usage"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; diff --git a/apps/web/src/features/environment-templates/TemplateDetail.tsx b/apps/web/src/features/environment-templates/TemplateDetail.tsx index 46fc721bd..b7bcaf7a7 100644 --- a/apps/web/src/features/environment-templates/TemplateDetail.tsx +++ b/apps/web/src/features/environment-templates/TemplateDetail.tsx @@ -7,7 +7,7 @@ import type { EnvironmentTemplateResource, EnvironmentTemplateSection, EnvironmentTemplateSkill, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import { PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { formatBytes, formatDateTime, MISSING } from "../../lib/format"; diff --git a/apps/web/src/features/environment-templates/TemplatesPage.test.tsx b/apps/web/src/features/environment-templates/TemplatesPage.test.tsx index ffe6f976f..748231f92 100644 --- a/apps/web/src/features/environment-templates/TemplatesPage.test.tsx +++ b/apps/web/src/features/environment-templates/TemplatesPage.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; -import type { EnvironmentTemplate, EnvironmentTemplateResource } from "@agents-core-web/agents-client"; +import type { EnvironmentTemplate, EnvironmentTemplateResource } from "@oac/agents-client"; import { filterTemplates } from "./template-name"; import { TemplateDetailPage, skillVersionLabel } from "./TemplateDetail"; import i18n from "../../i18n"; diff --git a/apps/web/src/features/environment-templates/TemplatesPage.tsx b/apps/web/src/features/environment-templates/TemplatesPage.tsx index 4eb4e1fee..1bb8ead31 100644 --- a/apps/web/src/features/environment-templates/TemplatesPage.tsx +++ b/apps/web/src/features/environment-templates/TemplatesPage.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentTemplateResource } from "@agents-core-web/agents-client"; +import type { EnvironmentTemplateResource } from "@oac/agents-client"; import { Boxes } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/environment-templates/template-name.ts b/apps/web/src/features/environment-templates/template-name.ts index aacab4c95..11bc1802b 100644 --- a/apps/web/src/features/environment-templates/template-name.ts +++ b/apps/web/src/features/environment-templates/template-name.ts @@ -1,4 +1,4 @@ -import type { EnvironmentTemplateResource } from "@agents-core-web/agents-client"; +import type { EnvironmentTemplateResource } from "@oac/agents-client"; import i18n from "../../i18n"; diff --git a/apps/web/src/features/files/FilesPage.tsx b/apps/web/src/features/files/FilesPage.tsx index 2d5f9d8dd..6b6d6d38a 100644 --- a/apps/web/src/features/files/FilesPage.tsx +++ b/apps/web/src/features/files/FilesPage.tsx @@ -1,4 +1,4 @@ -import type { PageOrder, SourceFileListEntry } from "@agents-core-web/agents-client"; +import type { PageOrder, SourceFileListEntry } from "@oac/agents-client"; import { FileText } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/files/file-operations.test.ts b/apps/web/src/features/files/file-operations.test.ts index 5eb0505ad..73b0a842c 100644 --- a/apps/web/src/features/files/file-operations.test.ts +++ b/apps/web/src/features/files/file-operations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type SourceFile, type SourceFileListEntry } from "@agents-core-web/agents-client"; +import { AgentCoreError, type SourceFile, type SourceFileListEntry } from "@oac/agents-client"; import i18n from "../../i18n"; import { diff --git a/apps/web/src/features/files/file-operations.ts b/apps/web/src/features/files/file-operations.ts index 73ea8a936..7e39a2d23 100644 --- a/apps/web/src/features/files/file-operations.ts +++ b/apps/web/src/features/files/file-operations.ts @@ -3,7 +3,7 @@ import { type CoreProjectReader, type PageOrder, type SourceFileListEntry, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import { appendCollectionPage } from "../../lib/collection-pagination"; diff --git a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx index 6ab8097f2..ee8c03aaf 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment, SandboxReset } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxReset } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; diff --git a/apps/web/src/features/fleet/SandboxResetNotice.tsx b/apps/web/src/features/fleet/SandboxResetNotice.tsx index 28a514771..ac852e227 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { Section, StatusDot } from "../../components/console-ui"; diff --git a/apps/web/src/features/fleet/fleet-model.ts b/apps/web/src/features/fleet/fleet-model.ts index c62946126..08abf927c 100644 --- a/apps/web/src/features/fleet/fleet-model.ts +++ b/apps/web/src/features/fleet/fleet-model.ts @@ -1,4 +1,4 @@ -import type { SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxNode } from "@oac/agents-client"; /** * Pure projections of the deployment fleet. Missing inputs stay null, never zero. diff --git a/apps/web/src/features/fleet/fleet-queries.test.ts b/apps/web/src/features/fleet/fleet-queries.test.ts index 25b8342b6..2663adad9 100644 --- a/apps/web/src/features/fleet/fleet-queries.test.ts +++ b/apps/web/src/features/fleet/fleet-queries.test.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { QueryClient } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/src/features/fleet/fleet-queries.ts b/apps/web/src/features/fleet/fleet-queries.ts index 275039af0..99c65e6f0 100644 --- a/apps/web/src/features/fleet/fleet-queries.ts +++ b/apps/web/src/features/fleet/fleet-queries.ts @@ -1,5 +1,5 @@ import { queryOptions, type QueryClient } from "@tanstack/react-query"; -import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxNodeHistoryRange } from "@agents-core-web/agents-client"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxNodeHistoryRange } from "@oac/agents-client"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; import { sandboxConsoleConfig } from "../sandbox/console-config"; diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 928d5c408..95c7ea02b 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.ts b/apps/web/src/features/fleet/use-sandbox-fleet.ts index 2f1c59cb9..3e13bbdea 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.ts +++ b/apps/web/src/features/fleet/use-sandbox-fleet.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-queries"; diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 08ede6ea6..f6c5f64af 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type CoreJobStatus, type CoreMetrics, type CoreMetricsRange } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreJobStatus, type CoreMetrics, type CoreMetricsRange } from "@oac/agents-client"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Network } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; diff --git a/apps/web/src/features/metrics/NodeHostCharts.tsx b/apps/web/src/features/metrics/NodeHostCharts.tsx index 173c0b155..8bb507a41 100644 --- a/apps/web/src/features/metrics/NodeHostCharts.tsx +++ b/apps/web/src/features/metrics/NodeHostCharts.tsx @@ -1,4 +1,4 @@ -import type { SandboxNodeDetail } from "@agents-core-web/agents-client"; +import type { SandboxNodeDetail } from "@oac/agents-client"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 8af46433b..14402658b 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -52,7 +52,7 @@ import "./MetricsView.css"; import { RuntimeCharts } from "./RuntimeCharts"; import { hostedRuntimesQuery } from "./metrics-queries"; import { SessionRuntimeSection } from "../sessions/SessionRuntimeSection"; -import type { SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxNode } from "@oac/agents-client"; const healthTone: Record = { available: "ok", degraded: "warning", offline: "danger" }; diff --git a/apps/web/src/features/metrics/agent-metrics-loader.ts b/apps/web/src/features/metrics/agent-metrics-loader.ts index 7134b5c43..a89f17c5c 100644 --- a/apps/web/src/features/metrics/agent-metrics-loader.ts +++ b/apps/web/src/features/metrics/agent-metrics-loader.ts @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn, ListPage, CoreProjectReader, PageOptions, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, ListPage, CoreProjectReader, PageOptions, SessionItem } from "@oac/agents-client"; import type { MetricsCoverage, MetricsWindow, SessionActivity } from "./agent-metrics"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "./project-sessions"; diff --git a/apps/web/src/features/metrics/agent-metrics.test.ts b/apps/web/src/features/metrics/agent-metrics.test.ts index e8555ef25..d8fcf96ce 100644 --- a/apps/web/src/features/metrics/agent-metrics.test.ts +++ b/apps/web/src/features/metrics/agent-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentSession, AgentTurn, ListPage, PageOptions, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, ListPage, PageOptions, SessionItem } from "@oac/agents-client"; import { aggregateAgentMetrics, diff --git a/apps/web/src/features/metrics/agent-metrics.ts b/apps/web/src/features/metrics/agent-metrics.ts index 17291665c..22c93bc05 100644 --- a/apps/web/src/features/metrics/agent-metrics.ts +++ b/apps/web/src/features/metrics/agent-metrics.ts @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, SessionItem } from "@oac/agents-client"; /** * Agent metrics derived in the browser from each project's Session, Turn and diff --git a/apps/web/src/features/metrics/metrics-queries.ts b/apps/web/src/features/metrics/metrics-queries.ts index 7443ff012..6ebb85207 100644 --- a/apps/web/src/features/metrics/metrics-queries.ts +++ b/apps/web/src/features/metrics/metrics-queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { CoreMetricsClient, type CoreMetricsRange } from "@agents-core-web/agents-client"; +import { CoreMetricsClient, type CoreMetricsRange } from "@oac/agents-client"; import { listRuntimeObservations, loadSummary, type Project } from "../../lib/admin-view"; import { projectClient } from "../../lib/projects"; diff --git a/apps/web/src/features/metrics/project-sessions.ts b/apps/web/src/features/metrics/project-sessions.ts index 754865eaa..ba9153e00 100644 --- a/apps/web/src/features/metrics/project-sessions.ts +++ b/apps/web/src/features/metrics/project-sessions.ts @@ -1,4 +1,4 @@ -import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@oac/agents-client"; import type { Owned } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; diff --git a/apps/web/src/features/metrics/sandbox-runtime.test.ts b/apps/web/src/features/metrics/sandbox-runtime.test.ts index de4690a72..a398e72cc 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.test.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SandboxAllocation } from "@agents-core-web/agents-client"; +import type { SandboxAllocation } from "@oac/agents-client"; import { hostedObservation, node, session } from "../overview/test-fixtures"; import { hostedRuntimeRows, hostedRuntimeUsage, loadHostedRuntimes, matchesRuntime, runtimeSnapshot } from "./sandbox-runtime"; diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index 372d47cef..a9645d827 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentSession, CoreProjectReader, SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader, SandboxAllocation, SandboxNode } from "@oac/agents-client"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { type OwnedRuntimeObservation } from "../../lib/admin-view"; diff --git a/apps/web/src/features/overview/FleetTopology.tsx b/apps/web/src/features/overview/FleetTopology.tsx index c1398c148..2fa3789a7 100644 --- a/apps/web/src/features/overview/FleetTopology.tsx +++ b/apps/web/src/features/overview/FleetTopology.tsx @@ -1,4 +1,4 @@ -import type { SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxNode } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { Cloud, Network } from "lucide-react"; import type { ReactNode } from "react"; diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index d6a2e3707..7f8e3e9bb 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, type CSSProperties, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 4f31b385d..1af2a70be 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -1,4 +1,4 @@ -import type { CoreHarness, SandboxDeployment } from "@agents-core-web/agents-client"; +import type { CoreHarness, SandboxDeployment } from "@oac/agents-client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { FleetState } from "../fleet/use-sandbox-fleet"; diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 0fa930fb5..92477c184 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -1,4 +1,4 @@ -import type { CoreHarness } from "@agents-core-web/agents-client"; +import type { CoreHarness } from "@oac/agents-client"; import { type Project } from "../../lib/admin-view"; import { nodeServingReady } from "../fleet/fleet-model"; diff --git a/apps/web/src/features/overview/overview-loader.test.ts b/apps/web/src/features/overview/overview-loader.test.ts index f13a0eddd..bcb002a91 100644 --- a/apps/web/src/features/overview/overview-loader.test.ts +++ b/apps/web/src/features/overview/overview-loader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { loadOverview, needsSessionRead } from "./overview-loader"; import { activityStart } from "./overview-model"; diff --git a/apps/web/src/features/overview/overview-loader.ts b/apps/web/src/features/overview/overview-loader.ts index 930f7d785..eba9be5c2 100644 --- a/apps/web/src/features/overview/overview-loader.ts +++ b/apps/web/src/features/overview/overview-loader.ts @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "../metrics/project-sessions"; import { activityStart, attentionCount, overviewReadDone, projectRows } from "./overview-model"; diff --git a/apps/web/src/features/overview/overview-model.test.ts b/apps/web/src/features/overview/overview-model.test.ts index 79bb72f0c..d431ef8a3 100644 --- a/apps/web/src/features/overview/overview-model.test.ts +++ b/apps/web/src/features/overview/overview-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { capacitySummary } from "../fleet/fleet-model"; import { project, session, summary } from "./test-fixtures"; diff --git a/apps/web/src/features/overview/overview-model.ts b/apps/web/src/features/overview/overview-model.ts index 28c624afb..ef13afa7b 100644 --- a/apps/web/src/features/overview/overview-model.ts +++ b/apps/web/src/features/overview/overview-model.ts @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import type { CapacitySummary } from "../fleet/fleet-model"; import type { InProject } from "../metrics/project-sessions"; diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 2ad47b8d1..9b54d3e6f 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -1,4 +1,4 @@ -import type { AgentSession, SandboxNode } from "@agents-core-web/agents-client"; +import type { AgentSession, SandboxNode } from "@oac/agents-client"; import { type OwnedRuntimeObservation, type Project, type ProjectSummary } from "../../lib/admin-view"; /** Fixtures shared by the Monitor page tests. Not part of the application bundle. */ diff --git a/apps/web/src/features/resources/detail-queries.ts b/apps/web/src/features/resources/detail-queries.ts index 2ba12c04b..506fb73ed 100644 --- a/apps/web/src/features/resources/detail-queries.ts +++ b/apps/web/src/features/resources/detail-queries.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type EnvironmentTemplateResource, type SavedAgent, type Skill, type Vault, type VaultCredential } from "@agents-core-web/agents-client"; +import { AgentCoreError, type EnvironmentTemplateResource, type SavedAgent, type Skill, type Vault, type VaultCredential } from "@oac/agents-client"; import { queryOptions, useQuery, useQueryClient, type QueryClient, type QueryKey, type UseQueryOptions } from "@tanstack/react-query"; import { useCallback } from "react"; diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 24ed17056..c5bad4f34 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -1,4 +1,4 @@ -import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@oac/agents-client"; import { suspendedSandboxes } from "../fleet/fleet-model"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 662f3dca3..3e562e68e 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -1,5 +1,5 @@ import { useId, useState } from "react"; -import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@agents-core-web/agents-client"; +import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 1099b0796..af0a07e1a 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,6 +1,6 @@ import { createPortal } from "react-dom"; import { useCallback, useEffect, useId, useRef, useState } from "react"; -import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; diff --git a/apps/web/src/features/sandbox/NodeList.test.ts b/apps/web/src/features/sandbox/NodeList.test.ts index 338fafef1..d32fab58e 100644 --- a/apps/web/src/features/sandbox/NodeList.test.ts +++ b/apps/web/src/features/sandbox/NodeList.test.ts @@ -1,4 +1,4 @@ -import type { SandboxAllocation } from "@agents-core-web/agents-client"; +import type { SandboxAllocation } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import { node } from "../overview/test-fixtures"; diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 0efe7699a..aa8bb7d50 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -1,4 +1,4 @@ -import type { SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxAllocation, SandboxNode } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; diff --git a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx index 6e3004086..f7a074c2a 100644 --- a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx +++ b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx @@ -1,4 +1,4 @@ -import type { SandboxNode, SandboxNodeRollout } from "@agents-core-web/agents-client"; +import type { SandboxNode, SandboxNodeRollout } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import type { MessageKey } from "../../lib/locale-strings"; diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 3e44cb9d9..e06289e53 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import type { InitializeSandboxDeployment, UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; +import type { InitializeSandboxDeployment, UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index e7e78e873..a59d15841 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import type { UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; +import type { UpdateSandboxDeployment, SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { HelpTip, RefreshButton } from "../../components/console-ui"; diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 492dad044..64a1dfcdb 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; -import { type SandboxNode } from "@agents-core-web/agents-client"; +import { type SandboxNode } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index 128253eeb..06290f867 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment, SandboxReset } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxReset } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { SandboxResetControls } from "./SandboxResetControls"; diff --git a/apps/web/src/features/sandbox/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx index ba67215de..d269eff98 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment, StartSandboxReset } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, StartSandboxReset } from "@oac/agents-client"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 45751c532..4d1a7e246 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { node } from "../overview/test-fixtures"; diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx index e2a7abc46..5a1f99de7 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index a7b3cdeb8..e6ea4f068 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@agents-core-web/agents-client"; +import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index 7d565a3f8..091a27056 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -1,4 +1,4 @@ -import type { CoreInstallation } from "@agents-core-web/agents-client"; +import type { CoreInstallation } from "@oac/agents-client"; import { isValidDirectCoreBaseUrl } from "../../lib/connection"; diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index f66483546..2ef2c5e08 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSandboxResources, distributionRuntime, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; import { isRuntimeReleaseField } from "./runtime-release"; import standardSizes from "./standard-sizes.json"; -import type { SandboxSpecification } from "@agents-core-web/agents-client"; +import type { SandboxSpecification } from "@oac/agents-client"; const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), images: { runtime: `sha256:${"a".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"b".repeat(64)}` }, runtime_ref: `oac-runtime@sha256:${"b".repeat(64)}`, diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 4019b8c04..e1e7f46bf 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment, SandboxE2BTemplateBuild, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxE2BTemplateBuild, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@oac/agents-client"; import { RUNTIME_REF_PATTERN } from "./runtime-release"; import standardSizes from "./standard-sizes.json"; diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index c8e4eef41..48b9bf736 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -1,4 +1,4 @@ -import type { SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxEnrollment, SandboxNode } from "@oac/agents-client"; import type { MessageKey } from "../../lib/locale-strings"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; @@ -26,7 +26,7 @@ export interface HostPrerequisite { * - Docker: `provider_group` needs rootful Docker Engine running, its socket * group-accessible (0660) and, through `device_group`, owned by the docker * group, which the service user joins; and CPU and memory limits enforced (the - * node is ready only then: services/agents-api/internal/sandbox/providers/probe.go). + * node is ready only then: services/core/internal/sandbox/providers/probe.go). * It installs nothing; * - microsandbox: `provider_group` needs /dev/kvm, readable and writable by all or * group-accessible in the kvm group, and `prepare_runtime` the libraries its diff --git a/apps/web/src/features/sandbox/runtime-release.ts b/apps/web/src/features/sandbox/runtime-release.ts index 74b9515c6..89cd0dbfd 100644 --- a/apps/web/src/features/sandbox/runtime-release.ts +++ b/apps/web/src/features/sandbox/runtime-release.ts @@ -1,4 +1,4 @@ -import type { SandboxRuntimeRelease } from "@agents-core-web/agents-client"; +import type { SandboxRuntimeRelease } from "@oac/agents-client"; /** Core owns the Runtime image name, so the Web checks only the `@sha256:` shape. */ export const RUNTIME_REF_PATTERN = /^[a-z0-9][a-z0-9._-]*@sha256:[0-9a-f]{64}$/; 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 b9a2b54f7..4346c8664 100644 --- a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts +++ b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type SandboxDeployment } from "@agents-core-web/agents-client"; +import { AgentCoreError, type SandboxDeployment } from "@oac/agents-client"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxAdmin, sandboxDeploymentQuery } from "./sandbox-queries"; diff --git a/apps/web/src/features/sandbox/sandbox-deployment-write.ts b/apps/web/src/features/sandbox/sandbox-deployment-write.ts index 7a377a47c..a219a30e1 100644 --- a/apps/web/src/features/sandbox/sandbox-deployment-write.ts +++ b/apps/web/src/features/sandbox/sandbox-deployment-write.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import type { QueryClient } from "@tanstack/react-query"; import { sandboxWriteUncertain } from "../../lib/sandbox-labels"; import { sandboxDeploymentQuery, sandboxScope } from "./sandbox-queries"; 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 cb869e3d4..69513800f 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { describe, expect, it, vi } from "vitest"; import type { SandboxConsoleConfig } from "./console-config"; import { SandboxManagerView } from "./SandboxManagerView"; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 5268392cf..4938261d4 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -1,5 +1,5 @@ import { QueryClient } from "@tanstack/react-query"; -import type { SandboxDeployment, SandboxReset } from "@agents-core-web/agents-client"; +import type { SandboxDeployment, SandboxReset } from "@oac/agents-client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { node } from "../overview/test-fixtures"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxResetPollInterval, sandboxSnapshotQuery, sandboxScope } from "./sandbox-queries"; diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 0d31c78fb..9b3c8f766 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -1,4 +1,4 @@ -import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxProvider } from "@agents-core-web/agents-client"; +import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; import { queryOptions, useQuery } from "@tanstack/react-query"; import { confirmSandboxRead, startSandboxRead } from "./sandbox-write-ownership"; diff --git a/apps/web/src/features/sandbox/sandbox-update.ts b/apps/web/src/features/sandbox/sandbox-update.ts index 3eb4a5a2c..ed529bc55 100644 --- a/apps/web/src/features/sandbox/sandbox-update.ts +++ b/apps/web/src/features/sandbox/sandbox-update.ts @@ -1,4 +1,4 @@ -import type { UpdateSandboxDeployment } from "@agents-core-web/agents-client"; +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): NonNullable { diff --git a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx index 77e416818..958e8d87c 100644 --- a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx +++ b/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { SandboxDeployment } from "@oac/agents-client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx b/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx index fd5f0ad04..f676b207b 100644 --- a/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorConnectionPanel.tsx @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import type { ExecutorCredentialList } from "@oac/agents-client"; import { useId } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 9a6449023..b764877a8 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type ExecutorCredential, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import { AgentCoreError, type ExecutorCredential, type IssuedExecutorCredential } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useEffect, useState } from "react"; diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 94e537edb..6d12b4143 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -38,7 +38,7 @@ export function ExecutorInstallPanel({ install, archived, connected = false }: {

{t(archived ? "executor.install.archived" : "executor.install.steps")}

- {t("executor.install.guide")} + {t("executor.install.guide")} {install.kind === "ready" ? <> { if (value === "posix" || value === "powershell") setShell(value); }} /> diff --git a/apps/web/src/features/sessions/SessionDeleteDialog.tsx b/apps/web/src/features/sessions/SessionDeleteDialog.tsx index 778ee2e7a..388af654b 100644 --- a/apps/web/src/features/sessions/SessionDeleteDialog.tsx +++ b/apps/web/src/features/sessions/SessionDeleteDialog.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, isSessionDeletionConflict } from "@agents-core-web/agents-client"; +import { AgentCoreError, isSessionDeletionConflict } from "@oac/agents-client"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 512f49850..bdf213eb0 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Trash2 } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index 03b44ea1a..aeadb58ff 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/SessionStatus.tsx b/apps/web/src/features/sessions/SessionStatus.tsx index de679bc46..ddacc237c 100644 --- a/apps/web/src/features/sessions/SessionStatus.tsx +++ b/apps/web/src/features/sessions/SessionStatus.tsx @@ -1,4 +1,4 @@ -import type { AgentSession } from "@agents-core-web/agents-client"; +import type { AgentSession } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; diff --git a/apps/web/src/features/sessions/SessionTranscript.tsx b/apps/web/src/features/sessions/SessionTranscript.tsx index 3c79156d8..73026423f 100644 --- a/apps/web/src/features/sessions/SessionTranscript.tsx +++ b/apps/web/src/features/sessions/SessionTranscript.tsx @@ -1,4 +1,4 @@ -import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentTurn, SessionItem } from "@oac/agents-client"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/SessionTurnsTable.test.tsx b/apps/web/src/features/sessions/SessionTurnsTable.test.tsx index b5abee94c..89ebbff95 100644 --- a/apps/web/src/features/sessions/SessionTurnsTable.test.tsx +++ b/apps/web/src/features/sessions/SessionTurnsTable.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentTurn, SessionItem } from "@oac/agents-client"; import { SessionTurnsTable } from "./SessionTurnsTable"; diff --git a/apps/web/src/features/sessions/SessionTurnsTable.tsx b/apps/web/src/features/sessions/SessionTurnsTable.tsx index 741898d2b..2108c3022 100644 --- a/apps/web/src/features/sessions/SessionTurnsTable.tsx +++ b/apps/web/src/features/sessions/SessionTurnsTable.tsx @@ -1,4 +1,4 @@ -import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentTurn, SessionItem } from "@oac/agents-client"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/executor-connection-query.ts b/apps/web/src/features/sessions/executor-connection-query.ts index 6441f6bda..75cb43db6 100644 --- a/apps/web/src/features/sessions/executor-connection-query.ts +++ b/apps/web/src/features/sessions/executor-connection-query.ts @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import type { ExecutorCredentialList } from "@oac/agents-client"; import { queryOptions } from "@tanstack/react-query"; import { admin } from "../../lib/projects"; diff --git a/apps/web/src/features/sessions/executor-connection.test.ts b/apps/web/src/features/sessions/executor-connection.test.ts index cd21fe1d7..3a9bd0e1c 100644 --- a/apps/web/src/features/sessions/executor-connection.test.ts +++ b/apps/web/src/features/sessions/executor-connection.test.ts @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import type { ExecutorCredentialList } from "@oac/agents-client"; import { QueryClient } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/src/features/sessions/executor-connection.ts b/apps/web/src/features/sessions/executor-connection.ts index 85e309ee9..efbf4cd81 100644 --- a/apps/web/src/features/sessions/executor-connection.ts +++ b/apps/web/src/features/sessions/executor-connection.ts @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import type { ExecutorCredentialList } from "@oac/agents-client"; export type ExecutorConnectionState = "never_enrolled" | "connected" | "disconnected" | "revoked" | "unknown"; diff --git a/apps/web/src/features/sessions/executor-credential-file.tsx b/apps/web/src/features/sessions/executor-credential-file.tsx index d6f2c3723..ed0c818a2 100644 --- a/apps/web/src/features/sessions/executor-credential-file.tsx +++ b/apps/web/src/features/sessions/executor-credential-file.tsx @@ -1,4 +1,4 @@ -import type { IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import type { IssuedExecutorCredential } from "@oac/agents-client"; import { Check, Copy, Download } from "lucide-react"; import { useEffect, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/executor-credential-ownership.test.tsx b/apps/web/src/features/sessions/executor-credential-ownership.test.tsx index b8b7fef13..e10fa04cc 100644 --- a/apps/web/src/features/sessions/executor-credential-ownership.test.tsx +++ b/apps/web/src/features/sessions/executor-credential-ownership.test.tsx @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@agents-core-web/agents-client"; +import type { ExecutorCredentialList } from "@oac/agents-client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/apps/web/src/features/sessions/items/ApplyPatchDiffViewer.tsx b/apps/web/src/features/sessions/items/ApplyPatchDiffViewer.tsx index fb803be32..56b593acc 100644 --- a/apps/web/src/features/sessions/items/ApplyPatchDiffViewer.tsx +++ b/apps/web/src/features/sessions/items/ApplyPatchDiffViewer.tsx @@ -2,7 +2,7 @@ import { ChevronRight, FileDiff } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { SessionItem } from "@agents-core-web/agents-client"; +import type { SessionItem } from "@oac/agents-client"; import type { ParsedApplyPatch } from "./apply-patch"; diff --git a/apps/web/src/features/sessions/items/ItemRenderers.tsx b/apps/web/src/features/sessions/items/ItemRenderers.tsx index cbf89f8a9..38a4ea52f 100644 --- a/apps/web/src/features/sessions/items/ItemRenderers.tsx +++ b/apps/web/src/features/sessions/items/ItemRenderers.tsx @@ -2,7 +2,7 @@ import { AlertTriangle, ChevronDown, ChevronRight, Search, TerminalSquare, Wrenc import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { SessionItem } from "@agents-core-web/agents-client"; +import type { SessionItem } from "@oac/agents-client"; import { Monogram } from "../../../components/atoms/EntityChip"; import { Shimmer } from "../../../components/atoms/Shimmer"; diff --git a/apps/web/src/features/sessions/items/apply-patch.test.tsx b/apps/web/src/features/sessions/items/apply-patch.test.tsx index 62bc1b124..b98d94696 100644 --- a/apps/web/src/features/sessions/items/apply-patch.test.tsx +++ b/apps/web/src/features/sessions/items/apply-patch.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { SessionItem } from "@agents-core-web/agents-client"; +import type { SessionItem } from "@oac/agents-client"; import { ApplyPatchDiffViewer } from "./ApplyPatchDiffViewer"; import { parseParsarApplyPatch } from "./apply-patch"; diff --git a/apps/web/src/features/sessions/items/item-status.test.tsx b/apps/web/src/features/sessions/items/item-status.test.tsx index 68f808a10..e2f26389c 100644 --- a/apps/web/src/features/sessions/items/item-status.test.tsx +++ b/apps/web/src/features/sessions/items/item-status.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { SessionItem } from "@agents-core-web/agents-client"; +import type { SessionItem } from "@oac/agents-client"; import { ThreadItems } from "./ItemRenderers"; describe("nullable protocol Item status", () => { diff --git a/apps/web/src/features/sessions/session-diagnostics.test.tsx b/apps/web/src/features/sessions/session-diagnostics.test.tsx index 0057c4c00..d7c8edc77 100644 --- a/apps/web/src/features/sessions/session-diagnostics.test.tsx +++ b/apps/web/src/features/sessions/session-diagnostics.test.tsx @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn, DiagnosticFailure, SessionDiagnostics, TurnDiagnostics } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, DiagnosticFailure, SessionDiagnostics, TurnDiagnostics } from "@oac/agents-client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/apps/web/src/features/sessions/session-diagnostics.tsx b/apps/web/src/features/sessions/session-diagnostics.tsx index 2bee28d8d..d880c297c 100644 --- a/apps/web/src/features/sessions/session-diagnostics.tsx +++ b/apps/web/src/features/sessions/session-diagnostics.tsx @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn } from "@oac/agents-client"; import { queryOptions, useQuery } from "@tanstack/react-query"; import { createContext, useContext } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/session-history.test.ts b/apps/web/src/features/sessions/session-history.test.ts index cd186c1a0..1e9757a08 100644 --- a/apps/web/src/features/sessions/session-history.test.ts +++ b/apps/web/src/features/sessions/session-history.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { AgentCoreError, type AgentSession, type AgentTurn, type ListPage, type SessionItem } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type AgentTurn, type ListPage, type SessionItem } from "@oac/agents-client"; import { classifyDeleteError } from "./SessionDeleteDialog"; import { diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index 59c0220c2..07a1a788e 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -4,7 +4,7 @@ import type { ListPage, CoreProjectReader, SessionItem, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; /** * Read-only Session history for the administrator. The Web API offers no event diff --git a/apps/web/src/features/sessions/session-log.test.ts b/apps/web/src/features/sessions/session-log.test.ts index a1629ed12..d1949a1ad 100644 --- a/apps/web/src/features/sessions/session-log.test.ts +++ b/apps/web/src/features/sessions/session-log.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentSession, TolerantSessionList } from "@agents-core-web/agents-client"; +import type { AgentSession, TolerantSessionList } from "@oac/agents-client"; import type { Owned } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; diff --git a/apps/web/src/features/sessions/session-log.ts b/apps/web/src/features/sessions/session-log.ts index 4f81f8df6..ff8c00360 100644 --- a/apps/web/src/features/sessions/session-log.ts +++ b/apps/web/src/features/sessions/session-log.ts @@ -1,4 +1,4 @@ -import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@oac/agents-client"; import type { Owned } from "../../lib/projects"; diff --git a/apps/web/src/features/sessions/session-queries.test.ts b/apps/web/src/features/sessions/session-queries.test.ts index 1d360e270..a3cc575dc 100644 --- a/apps/web/src/features/sessions/session-queries.test.ts +++ b/apps/web/src/features/sessions/session-queries.test.ts @@ -1,7 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type AgentSession, type AgentTurn, type ListPage, type SessionItem } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type AgentTurn, type ListPage, type SessionItem } from "@oac/agents-client"; import { retryTransient } from "../resources/detail-queries"; import { SESSION_POLL_MS } from "./session-history"; diff --git a/apps/web/src/features/sessions/session-queries.ts b/apps/web/src/features/sessions/session-queries.ts index 219442e0c..2bf0edf98 100644 --- a/apps/web/src/features/sessions/session-queries.ts +++ b/apps/web/src/features/sessions/session-queries.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentSession, type RuntimeObservation } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type RuntimeObservation } from "@oac/agents-client"; import { queryOptions } from "@tanstack/react-query"; import { projectClient } from "../../lib/projects"; diff --git a/apps/web/src/features/sessions/session-runtime.ts b/apps/web/src/features/sessions/session-runtime.ts index e04927601..250bd63ba 100644 --- a/apps/web/src/features/sessions/session-runtime.ts +++ b/apps/web/src/features/sessions/session-runtime.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentSession, type CoreProjectReader } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader } from "@oac/agents-client"; import { RUNTIME_DURABLE_MAX_POINTS, RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; diff --git a/apps/web/src/features/sessions/trace/ItemReceiptTiming.tsx b/apps/web/src/features/sessions/trace/ItemReceiptTiming.tsx index 6173794a4..afb43c7ec 100644 --- a/apps/web/src/features/sessions/trace/ItemReceiptTiming.tsx +++ b/apps/web/src/features/sessions/trace/ItemReceiptTiming.tsx @@ -1,6 +1,6 @@ import "./receipt-timing.css"; -import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentTurn, SessionItem } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { useContext } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/sessions/trace/TraceView.test.tsx b/apps/web/src/features/sessions/trace/TraceView.test.tsx index dfd48f961..e69e13eea 100644 --- a/apps/web/src/features/sessions/trace/TraceView.test.tsx +++ b/apps/web/src/features/sessions/trace/TraceView.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import type { AgentSession, AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, SessionItem } from "@oac/agents-client"; import { TraceView } from "./TraceView"; diff --git a/apps/web/src/features/sessions/trace/TraceView.tsx b/apps/web/src/features/sessions/trace/TraceView.tsx index afcf495e7..94810fa00 100644 --- a/apps/web/src/features/sessions/trace/TraceView.tsx +++ b/apps/web/src/features/sessions/trace/TraceView.tsx @@ -23,7 +23,7 @@ import type { AgentSession, AgentTurn, SessionItem, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import { TraceTimingPanel } from "./TraceTimingPanel"; import { TurnFailure } from "../session-diagnostics"; diff --git a/apps/web/src/features/sessions/trace/trace-model.test.ts b/apps/web/src/features/sessions/trace/trace-model.test.ts index 36e2d06a0..083b340d6 100644 --- a/apps/web/src/features/sessions/trace/trace-model.test.ts +++ b/apps/web/src/features/sessions/trace/trace-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentTurn, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentTurn, SessionItem } from "@oac/agents-client"; import { buildTraceModel, filterTraceModel, type TraceAgentSnapshot } from "./trace-model"; diff --git a/apps/web/src/features/sessions/trace/trace-model.ts b/apps/web/src/features/sessions/trace/trace-model.ts index f4de4046c..fbf6a1693 100644 --- a/apps/web/src/features/sessions/trace/trace-model.ts +++ b/apps/web/src/features/sessions/trace/trace-model.ts @@ -3,7 +3,7 @@ import type { AgentTurn, ItemStatus, SessionItem, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; export type TraceValueState = "available" | "unavailable" | "unknown"; diff --git a/apps/web/src/features/skills/SkillDetail.test.tsx b/apps/web/src/features/skills/SkillDetail.test.tsx index ecfbe03de..fa3651a85 100644 --- a/apps/web/src/features/skills/SkillDetail.test.tsx +++ b/apps/web/src/features/skills/SkillDetail.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; -import type { Skill, SkillVersion, SkillVersionList } from "@agents-core-web/agents-client"; +import type { Skill, SkillVersion, SkillVersionList } from "@oac/agents-client"; import { SkillDetailPage, type SkillDetailPageProps, type SkillVersionsState } from "./SkillDetail"; diff --git a/apps/web/src/features/skills/SkillDetail.tsx b/apps/web/src/features/skills/SkillDetail.tsx index 6303bc25d..cf504ad38 100644 --- a/apps/web/src/features/skills/SkillDetail.tsx +++ b/apps/web/src/features/skills/SkillDetail.tsx @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AgentCoreError, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; +import { AgentCoreError, type Skill, type SkillVersion } from "@oac/agents-client"; import type { ProjectClient } from "../../lib/projects"; import { collections } from "../../lib/queries"; diff --git a/apps/web/src/features/skills/SkillsPage.tsx b/apps/web/src/features/skills/SkillsPage.tsx index 0b389c9e3..545a7087b 100644 --- a/apps/web/src/features/skills/SkillsPage.tsx +++ b/apps/web/src/features/skills/SkillsPage.tsx @@ -1,4 +1,4 @@ -import type { Skill } from "@agents-core-web/agents-client"; +import type { Skill } from "@oac/agents-client"; import { Puzzle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/skills/skill-operations.test.ts b/apps/web/src/features/skills/skill-operations.test.ts index de933463f..19b33cf65 100644 --- a/apps/web/src/features/skills/skill-operations.test.ts +++ b/apps/web/src/features/skills/skill-operations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type CoreProjectReader, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@oac/agents-client"; import i18n from "../../i18n"; import { diff --git a/apps/web/src/features/skills/skill-operations.ts b/apps/web/src/features/skills/skill-operations.ts index 0c74a1105..72c5d3297 100644 --- a/apps/web/src/features/skills/skill-operations.ts +++ b/apps/web/src/features/skills/skill-operations.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type CoreProjectReader, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillVersion } from "@oac/agents-client"; import { appendCollectionPage } from "../../lib/collection-pagination"; diff --git a/apps/web/src/features/skills/skill-parts.tsx b/apps/web/src/features/skills/skill-parts.tsx index 7ecdd8764..e568b12ce 100644 --- a/apps/web/src/features/skills/skill-parts.tsx +++ b/apps/web/src/features/skills/skill-parts.tsx @@ -2,7 +2,7 @@ import { Check, Copy } from "lucide-react"; import { useEffect, useState, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; -import type { Skill } from "@agents-core-web/agents-client"; +import type { Skill } from "@oac/agents-client"; import { StatusDot } from "../../components/console-ui"; import { shortId } from "../../lib/format"; diff --git a/apps/web/src/features/system/DefaultModelsSection.tsx b/apps/web/src/features/system/DefaultModelsSection.tsx index 31cb3a130..faa302c8d 100644 --- a/apps/web/src/features/system/DefaultModelsSection.tsx +++ b/apps/web/src/features/system/DefaultModelsSection.tsx @@ -1,4 +1,4 @@ -import { type CoreHarnessKind } from "@agents-core-web/agents-client"; +import { type CoreHarnessKind } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/system/HarnessCard.tsx b/apps/web/src/features/system/HarnessCard.tsx index 70c493835..550b8a2ec 100644 --- a/apps/web/src/features/system/HarnessCard.tsx +++ b/apps/web/src/features/system/HarnessCard.tsx @@ -1,4 +1,4 @@ -import type { CoreHarness } from "@agents-core-web/agents-client"; +import type { CoreHarness } from "@oac/agents-client"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import { StatusDot } from "../../components/console-ui"; diff --git a/apps/web/src/features/system/ModelProviderDialog.tsx b/apps/web/src/features/system/ModelProviderDialog.tsx index b6ebd73d5..3e555058e 100644 --- a/apps/web/src/features/system/ModelProviderDialog.tsx +++ b/apps/web/src/features/system/ModelProviderDialog.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@oac/agents-client"; import { useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConsoleSelect } from "../../components/console-select"; diff --git a/apps/web/src/features/system/ProviderObservations.tsx b/apps/web/src/features/system/ProviderObservations.tsx index 1f29dc144..2ba5a18a7 100644 --- a/apps/web/src/features/system/ProviderObservations.tsx +++ b/apps/web/src/features/system/ProviderObservations.tsx @@ -1,4 +1,4 @@ -import type { HarnessModelConfiguration } from "@agents-core-web/agents-client"; +import type { HarnessModelConfiguration } from "@oac/agents-client"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot } from "../../components/console-ui"; diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx index ce80da395..edcec53e2 100644 --- a/apps/web/src/features/system/StartupSettings.tsx +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -1,4 +1,4 @@ -import type { CoreInstallationConfiguration, CoreInstallationSetting } from "@agents-core-web/agents-client"; +import type { CoreInstallationConfiguration, CoreInstallationSetting } from "@oac/agents-client"; import { Trans, useTranslation } from "react-i18next"; import { ValuePill } from "../../components/atoms/ValuePill"; diff --git a/apps/web/src/features/system/provider-observations.test.tsx b/apps/web/src/features/system/provider-observations.test.tsx index a59a767ad..1cec14819 100644 --- a/apps/web/src/features/system/provider-observations.test.tsx +++ b/apps/web/src/features/system/provider-observations.test.tsx @@ -1,4 +1,4 @@ -import type { HarnessModelConfiguration } from "@agents-core-web/agents-client"; +import type { HarnessModelConfiguration } from "@oac/agents-client"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it } from "vitest"; import i18n from "../../i18n"; diff --git a/apps/web/src/features/vaults/VaultsPage.tsx b/apps/web/src/features/vaults/VaultsPage.tsx index 240d2cdc5..dfa8ea729 100644 --- a/apps/web/src/features/vaults/VaultsPage.tsx +++ b/apps/web/src/features/vaults/VaultsPage.tsx @@ -1,4 +1,4 @@ -import type { Vault, VaultCredential } from "@agents-core-web/agents-client"; +import type { Vault, VaultCredential } from "@oac/agents-client"; import { ArrowLeft, Trash2, Vault as VaultIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/features/vaults/vault-catalog.test.ts b/apps/web/src/features/vaults/vault-catalog.test.ts index 192f2e65c..fe2054743 100644 --- a/apps/web/src/features/vaults/vault-catalog.test.ts +++ b/apps/web/src/features/vaults/vault-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { CoreProjectReader, SavedAgent, Vault, VaultCredential } from "@agents-core-web/agents-client"; +import type { CoreProjectReader, SavedAgent, Vault, VaultCredential } from "@oac/agents-client"; import { deriveSessionVaultPlan, loadVaultCatalog, matchingCredentials, type VaultCatalog } from "./vault-catalog"; diff --git a/apps/web/src/features/vaults/vault-catalog.ts b/apps/web/src/features/vaults/vault-catalog.ts index d04367db7..4170ea55b 100644 --- a/apps/web/src/features/vaults/vault-catalog.ts +++ b/apps/web/src/features/vaults/vault-catalog.ts @@ -3,7 +3,7 @@ import type { SavedAgent, Vault, VaultCredential, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import { listAllCollectionPages } from "../../lib/collection-pagination"; import i18n from "../../i18n"; diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index d797b941c..a839ef932 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -1,4 +1,4 @@ -import { coreHarnessNames } from "@agents-core-web/agents-client"; +import { coreHarnessNames } from "@oac/agents-client"; /** * Session log and one Session's read-only history (Monitor › Session log). diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index e05e49a93..afaccc9e6 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -1,4 +1,4 @@ -import { coreHarnessNames } from "@agents-core-web/agents-client"; +import { coreHarnessNames } from "@oac/agents-client"; import { sessions as english } from "../en/sessions"; diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 455502696..9af9ead20 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -8,7 +8,7 @@ import { type AdminSummaryEntry, type AdminWriteOperation, type RuntimeObservation, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; /** * View models over the typed management client (`AdminClient`, `/core/v1`). diff --git a/apps/web/src/lib/collection-pagination.ts b/apps/web/src/lib/collection-pagination.ts index 69c0cd0f8..c9a73fb90 100644 --- a/apps/web/src/lib/collection-pagination.ts +++ b/apps/web/src/lib/collection-pagination.ts @@ -1,4 +1,4 @@ -import type { ListPage, PageOptions } from "@agents-core-web/agents-client"; +import type { ListPage, PageOptions } from "@oac/agents-client"; const COLLECTION_PAGE_LIMIT = 100; const COLLECTION_PAGE_SAFETY_LIMIT = 100; diff --git a/apps/web/src/lib/core-error.test.ts b/apps/web/src/lib/core-error.test.ts index 19b6d765d..8731487da 100644 --- a/apps/web/src/lib/core-error.test.ts +++ b/apps/web/src/lib/core-error.test.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import i18n from "../i18n"; import { coreErrors as english } from "../i18n/locales/en/core-errors"; diff --git a/apps/web/src/lib/core-error.ts b/apps/web/src/lib/core-error.ts index 256090178..9b14b7ef4 100644 --- a/apps/web/src/lib/core-error.ts +++ b/apps/web/src/lib/core-error.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import type { TFunction } from "i18next"; import type { coreErrors } from "../i18n/locales/en/core-errors"; diff --git a/apps/web/src/lib/delete-flow.ts b/apps/web/src/lib/delete-flow.ts index 0d63de3b1..31261f34c 100644 --- a/apps/web/src/lib/delete-flow.ts +++ b/apps/web/src/lib/delete-flow.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { useCallback, useState } from "react"; export interface DeleteFlow { diff --git a/apps/web/src/lib/diagnostic-failure.ts b/apps/web/src/lib/diagnostic-failure.ts index 0017e6cdb..9921c7f5a 100644 --- a/apps/web/src/lib/diagnostic-failure.ts +++ b/apps/web/src/lib/diagnostic-failure.ts @@ -1,4 +1,4 @@ -import type { DiagnosticFailure } from "@agents-core-web/agents-client"; +import type { DiagnosticFailure } from "@oac/agents-client"; import type { TFunction } from "i18next"; /** Translate the catalog, never native error text or guessed message categories. */ diff --git a/apps/web/src/lib/harness-labels.ts b/apps/web/src/lib/harness-labels.ts index 668b4d4c1..92a2848d1 100644 --- a/apps/web/src/lib/harness-labels.ts +++ b/apps/web/src/lib/harness-labels.ts @@ -1,7 +1,7 @@ -import type { ModelProviderView } from "@agents-core-web/agents-client"; +import type { ModelProviderView } from "@oac/agents-client"; /** Harnesses by their product names, the same in every language. */ -export { coreHarnessNames as harnessNames } from "@agents-core-web/agents-client"; +export { coreHarnessNames as harnessNames } from "@oac/agents-client"; /** Provider protocols by their product names, the same in every language. */ export const protocolNames: Record = { anthropic: "Anthropic Messages", responses: "OpenAI Responses", chat_completions: "OpenAI Chat Completions" }; diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index e25a2e28e..23074acb6 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { resolveLocale } from "./locale"; -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { sandboxRequestError, sandboxStateLabel } from "./sandbox-labels"; import { sandboxDiagnosticMessage } from "./sandbox-diagnostic"; diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index 636e6c037..3f3554bdb 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -1,4 +1,4 @@ -import type { CoreProjectReader } from "@agents-core-web/agents-client"; +import type { CoreProjectReader } from "@oac/agents-client"; import { QueryClientProvider, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 1d90c3e4f..f5b007d7f 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -1,5 +1,5 @@ import { QueryClient, queryOptions } from "@tanstack/react-query"; -import type { EnvironmentTemplateResource, SavedAgent, Skill, SourceFileListEntry, Vault } from "@agents-core-web/agents-client"; +import type { EnvironmentTemplateResource, SavedAgent, Skill, SourceFileListEntry, Vault } from "@oac/agents-client"; import { readSessionLog, type SessionLogEntry } from "../features/sessions/session-log"; import { listAllProjects } from "./admin-view"; diff --git a/apps/web/src/lib/read-error.test.ts b/apps/web/src/lib/read-error.test.ts index 39d691c92..618cc8bc8 100644 --- a/apps/web/src/lib/read-error.test.ts +++ b/apps/web/src/lib/read-error.test.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import i18n from "../i18n"; import { readError } from "./read-error"; diff --git a/apps/web/src/lib/read-error.ts b/apps/web/src/lib/read-error.ts index 83a56c320..bf2cf4cbd 100644 --- a/apps/web/src/lib/read-error.ts +++ b/apps/web/src/lib/read-error.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import type { TFunction } from "i18next"; import { coreError } from "./core-error"; diff --git a/apps/web/src/lib/sandbox-diagnostic.test.ts b/apps/web/src/lib/sandbox-diagnostic.test.ts index 61670f9ac..781a65087 100644 --- a/apps/web/src/lib/sandbox-diagnostic.test.ts +++ b/apps/web/src/lib/sandbox-diagnostic.test.ts @@ -1,4 +1,4 @@ -import type { SandboxNodeDiagnostic } from "@agents-core-web/agents-client"; +import type { SandboxNodeDiagnostic } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "./sandbox-diagnostic"; diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index 8475ba66c..f800e0c59 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -1,4 +1,4 @@ -import type { SandboxNode } from "@agents-core-web/agents-client"; +import type { SandboxNode } from "@oac/agents-client"; import { translate, type Locale } from "./locale"; import type { MessageKey } from "./locale-strings"; export interface SandboxDiagnosticMessage { label: string; advice: string } diff --git a/apps/web/src/lib/sandbox-labels.test.ts b/apps/web/src/lib/sandbox-labels.test.ts index 7405b0ed1..631d12573 100644 --- a/apps/web/src/lib/sandbox-labels.test.ts +++ b/apps/web/src/lib/sandbox-labels.test.ts @@ -1,4 +1,4 @@ -import { AgentCoreError } from "@agents-core-web/agents-client"; +import { AgentCoreError } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "./sandbox-labels"; diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 4fc5c1320..832126bd5 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type SandboxNode, type SandboxProvider } from "@agents-core-web/agents-client"; +import { AgentCoreError, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; import i18n from "../i18n"; import { knownCoreError } from "./core-error"; import { translate, type Locale } from "./locale"; diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 120b8f8eb..3311433d8 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -223,8 +223,8 @@ upgrade the protocol. without the encryption key. Missing encryption configuration locally rejects creation/replacement with 503. Attached Sessions can use static credentials for exact-URL HTTPS MCP. OAuth grants use the same binding plus - [scoped refresh and replacement](../../services/agents-api/oauth-credentials.md); - storage-key rotation, archive behavior and key scopes remain gaps. See the [credential storage guide](../../services/agents-api/credentials.md) + [scoped refresh and replacement](../../services/core/oauth-credentials.md); + storage-key rotation, archive behavior and key scopes remain gaps. See the [credential storage guide](../../services/core/credentials.md) for encryption and operational limits; this does not establish complete Credential or hosted error/retry compatibility. - `GET /vaults/{vault_id}/credentials` lists only safe metadata, with parent and @@ -488,10 +488,10 @@ and [public qualification](public-mcp-qualification.md). The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains workspace Files/Artifacts, cancellation and recovery. Managed isolation belongs to the outer Environment; native tools use the starting account's permissions. -Configure immutable Runtime images explicitly: [Codex](../../services/agents-api/deploy/codex/README.md), -[Claude](../../services/agents-api/deploy/claude/README.md), -[MiniMax](../../services/agents-api/deploy/mcode/README.md). -[E2B packaging](../../services/agents-api/deploy/e2b/README.md) reuses the Runtime +Configure immutable Runtime images explicitly: [Codex](../../services/core/deploy/codex/README.md), +[Claude](../../services/core/deploy/claude/README.md), +[MiniMax](../../services/core/deploy/mcode/README.md). +[E2B packaging](../../services/core/deploy/e2b/README.md) reuses the Runtime with the official SDK; the user owns provisioning, renewal and destruction. The shared initialization path supports env/setup and user-directory npm/Python packages; @@ -512,7 +512,7 @@ Unsupported configurations fail before Session creation; unsupported results fai before a batch write. Native capability claims cannot replace service profile qualification, tenant authority or exact binding checks. An existing Session never silently changes engine/device. See the -[HTTP MCP limits](../../services/agents-api/README.md#http-mcp-execution). +[HTTP MCP limits](../../services/core/README.md#http-mcp-execution). The Store's internal DTO is not the upstream response model. The API layer must validate and resolve the upstream schema before persistence, and report only @@ -586,7 +586,7 @@ authenticated tenant and may include the command's or tool's own diagnostic text Reads use the durable index without reconstructing native journals. Existing indexed history is preserved. Migration 15 refuses unindexed historical Turns. Historical database conversion is unsupported; preserve the old data and install -separately. See [historical Item storage](../../services/agents-api/README.md#historical-item-storage). +separately. See [historical Item storage](../../services/core/README.md#historical-item-storage). The retired archive format could not recover unrecorded message boundaries or outcomes; those limitations remain in already indexed historical Items. Other native variants, full reasoning coverage and Items mutation remain gaps. @@ -912,7 +912,7 @@ creation; rotation/revocation and current authorization reuse the durable ledger and exact Runtime enrollment/gateway binding. Historical keys remain revoked and unclaimed. This executor-specific prerequisite does not open public Environment admission or establish complete ownership, hosted key lifecycle or error compatibility. See the -[standalone configuration](../../services/agents-api/README.md#standalone-http-service). +[standalone configuration](../../services/core/README.md#standalone-http-service). Core documents its optional [harness selection extension](harness-selection.md) separately from the pinned upstream contract. diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index b47212d69..105cb6996 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -98,7 +98,7 @@ stored local profile, immutable exact device/Environment binding and live capabi It never starts a model for upload or supplies a filesystem root from the request. The deployment must qualify the protected sibling workspace/staging layout and its selected native adapter. The [engine profile guides](README.md#public-engine-profiles) -describe accepted Docker configurations; the [E2B operator guide](../../services/agents-api/deploy/e2b/README.md) +describe accepted Docker configurations; the [E2B operator guide](../../services/core/deploy/e2b/README.md) covers user-managed E2B Runtime packaging and links its separate real acceptance. A capability or path declaration alone does not establish isolation or public hosted admission. @@ -139,7 +139,7 @@ raw HTTP and pinned SDK pagination, sizes, and two-tenant isolation. It does not establish unspecified recursive, symlink or snapshot behavior. Runtime availability and each engine's isolated placement require their own native and service checks. -The opt-in `services/agents-api/tests/official_environment_files_create.py` reuses +The opt-in `services/core/tests/official_environment_files_create.py` reuses the pinned SDK and raw HTTP listing assertions. Its stdin supplies the base URL, preconfigured Environment ID, model-input text, and two private caller token sources (`token_env` or `token_file`). The invoking native fixture supplies an diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 27000f88c..c3bad53c5 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -246,7 +246,7 @@ passed, including dedicated real PostgreSQL checks and native packaging. Evidence is under `~/.parsar/remediation/20260921/template-plugins/`: `acceptance-summary.json`, `native-final-{codex,claude,mcode}.json`, `runtime-builds.json` and `full-check-attempt2-result.json`. The shared reproducible -fixture is `services/agents-api/tests/official_environment_plugins.py`; operator +fixture is `services/core/tests/official_environment_plugins.py`; operator runners reuse existing standalone acceptance and private model configuration. A user-authorized reused-context GPT-6 Astra high independent review of all 60 changed files found no material actionable findings. Those historical results do @@ -330,13 +330,13 @@ Per-run records retain exact Runtime image IDs and cleanup results. The final `make-check-final.log` passed; OpenAPI regeneration and focused Go/Claude tests also passed. Real Linux process checks cover idle creator-thread exit, native and wrapper exit, active-call cleanup and the reproduced pre-exec orphan window. -`services/agents-api/deploy/runtime/initialize_stdio_test.py` retains that OS +`services/core/deploy/runtime/initialize_stdio_test.py` retains that OS regression; it requires a disposable Linux packaged Runtime, not a model fixture. The Codex environment hook additionally passed 30 actual sh/Bash cases after its Bash-specific `eval --` failed under native `/bin/sh`. The reusable public fixture is -`services/agents-api/tests/official_environment_plugin_mcp.py`. Mechanism probes +`services/core/tests/official_environment_plugin_mcp.py`. Mechanism probes and failed attempts remain separate evidence. This batch does not qualify new Plugin MCP paths on E2B, service-origin hosted MCP, OAuth, unlisted transports or complete upstream protocol compatibility. @@ -347,7 +347,7 @@ This section records the historical September 21 fixture and binaries. Its system-package and inner-isolation checks are not current support requirements; `packages.system` now rejects and tools run with the starting user's permissions. -`services/agents-api/tests/official_environment_composition.py` combines the +`services/core/tests/official_environment_composition.py` combines the existing public fixtures in one enabled-network configuration: inline/referenced files, caller env, system/npm/Python packages, ordered setup, an uploaded Skill reference, Skill/MCP Plugins and exact capability-directory roots. Pass the same diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 62cc14360..bb509ed80 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -10,7 +10,7 @@ source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through [sandbox deployment](sandbox-deployment.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](../../services/agents-api/deploy/e2b/README.md). +cleanup through the official SDK and [Runtime packaging](../../services/core/deploy/e2b/README.md). [Templates](environment-templates.md) provide reusable preparation; the Core extension also applies their execution configuration to user-managed machines; [Files](environment-files.md) reuse the exact authorized local workspace. @@ -30,7 +30,7 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](../../services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](../../services/core/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile @@ -39,7 +39,7 @@ for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the sa Runtime lifecycle and workspace interfaces with its native adapter. The outer Environment provides managed isolation; the daemon adds no inner sandbox. See the [engine profile guides](README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](../../services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +The standalone [operator configuration](../../services/core/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -80,7 +80,7 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](../../services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](../../services/core/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger transparent replacement or replay. The [new enrollment qualification](user-managed-runtime-v1.md) records its own real deployment evidence. @@ -693,7 +693,7 @@ silently expanding it. Codex and Claude preserve native allowlists and initializ required servers before releasing native input, including cold recovery. Public MCP with native Subagents remains unqualified. Nonempty literal HTTP headers, request metadata and public stdio declarations remain unsupported. -See [public MCP qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, +See [public MCP qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, platform and infrastructure coverage; admission support is not a claim of complete cross-platform/provider qualification. diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 70f2efde8..d4b722515 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -19,21 +19,21 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](message-input.md), [initial parser](../../services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Inline PNG/JPEG is qualified on `none`, Core-managed Docker `openai_hosted` and `self_hosted`; MiniMax images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](../../services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts), plus [self-hosted execution and cold continuation](environment-capabilities-qualification.md). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](history-events-usage.md#item-serialization-2026-09-23)). [Contract](structured-output.md), [profile](../../services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](../../services/agents-api/internal/api/function_configuration.go), [saved tools](../../services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](../../services/agents-api/tests/official_function_inputs.py). [Parser](../../services/agents-api/internal/api/function_inputs.go), [Store](../../services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](message-input.md), [initial parser](../../services/core/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Inline PNG/JPEG is qualified on `none`, Core-managed Docker `openai_hosted` and `self_hosted`; MiniMax images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](../../services/core/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts), plus [self-hosted execution and cold continuation](environment-capabilities-qualification.md). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](history-events-usage.md#item-serialization-2026-09-23)). [Contract](structured-output.md), [profile](../../services/core/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](../../services/core/internal/api/function_configuration.go), [saved tools](../../services/core/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](../../services/core/tests/official_function_inputs.py). [Parser](../../services/core/internal/api/function_inputs.go), [Store](../../services/core/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | | Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. Self-hosted Claude image results use the same native path; see the current [qualification record](environment-capabilities-qualification.md). MiniMax functions remain unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | | Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading for D1. [Self-hosted workspace callback, continuation and cancellation](environment-capabilities-qualification.md) use the same native path; they do not add model-request observer evidence. [Contract](tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace with Skills/Plugins, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/agents-api/README.md#http-mcp-execution), [profiles](../../services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](environments.md#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/core/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/core/README.md#http-mcp-execution), [profiles](../../services/core/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](environments.md#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/OpenAgentCore/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | | Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](../../services/agents-api/internal/store/function_state.go), [confirmation](../../services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](../../services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](README.md#public-execution-admission), [connection reconciliation](../../services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](../../services/core/internal/store/function_state.go), [confirmation](../../services/core/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](../../services/core/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](README.md#public-execution-admission), [connection reconciliation](../../services/core/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -50,7 +50,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](../../services/agents-api/internal/store/environment_connections.go) +[Connection observations](../../services/core/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -59,7 +59,7 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](../../services/agents-api/internal/store/item_projection.go) and +[Item publication](../../services/core/internal/store/item_projection.go) and [the existing coverage record](README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -76,15 +76,15 @@ not a claim that every historical workflow was rerun at the current baseline. | ID | Exact evidence and accepted scope | | --- | --- | -| M1 | `20260922/message-image-input/public-codex-reviewed.log` (59.41s), `public-claude-reviewed.log` (79.52s), `public-mcode-network-fixed.log` (48.95s); [PR #12](https://github.com/MiniMax-AI/parsar-core/pull/12), merge `37a2923`. Codex/Claude Kimi K3 PNG initial/active inputs, receipts, retries, cancellation, cold continuation and isolation; MiniMax M2.7 ordinary text regression. | -| M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | -| F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | -| F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](../../services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | -| S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | -| S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | -| D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | -| P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | +| M1 | `20260922/message-image-input/public-codex-reviewed.log` (59.41s), `public-claude-reviewed.log` (79.52s), `public-mcode-network-fixed.log` (48.95s); [PR #12](https://github.com/MiniMax-AI/OpenAgentCore/pull/12), merge `37a2923`. Codex/Claude Kimi K3 PNG initial/active inputs, receipts, retries, cancellation, cold continuation and isolation; MiniMax M2.7 ordinary text regression. | +| M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/OpenAgentCore/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | +| F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/OpenAgentCore/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | +| F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/OpenAgentCore/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](../../services/core/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/OpenAgentCore/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | +| S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/OpenAgentCore/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | +| D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/OpenAgentCore/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | +| P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/OpenAgentCore/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | | E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | diff --git a/contracts/agents-api/harness-catalog.md b/contracts/agents-api/harness-catalog.md index 842296853..5990b3c9e 100644 --- a/contracts/agents-api/harness-catalog.md +++ b/contracts/agents-api/harness-catalog.md @@ -13,7 +13,7 @@ by `make check-harness-catalog`. | `mcode` | MiniMax Code | `mcode.Configuration` | `mcodeProfile` | Configuration declarations live in `internal/harnessconfig/`; -qualification constructors live in `services/agents-api/internal/engine`. +qualification constructors live in `services/core/internal/engine`. These registrations describe the build. Deployment enablement, qualified operations and a connected Runtime's actual availability remain separate checks. See [Harness onboarding](harness-onboarding.md) for adapter and packaging steps. diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index aeb7dae9b..901e7f777 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -10,7 +10,7 @@ Start from two entry points: - [`internal/harnessconfig/harness.go`](../../internal/harnessconfig/harness.go): the shared model configuration contract (declarations and preparation). -- [`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go): the +- [`agent/harness.go`](../../apps/daemon/internal/agent/harness.go): the execution lifecycle, explicit extension contracts and registration methods. @@ -30,12 +30,12 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Component | Responsibility | Location | | --- | --- | --- | -| Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/agents-api` | -| Runtime | Authenticated connection, shared capability preparation and common Executor/Turn lifecycle | `apps/parsar-daemon/internal/dispatch` | -| Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/parsar-daemon/internal/agent/` | +| Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/core` | +| Runtime | Authenticated connection, shared capability preparation and common Executor/Turn lifecycle | `apps/daemon/internal/dispatch` | +| Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/daemon/internal/agent/` | | Harness | Native model/tool loop and history | Pinned SDK or executable | -| Service profile | Pure validation of qualified operations and placements | `services/agents-api/internal/engine` | -| Registration | Installed factories and verified capability declarations | `apps/parsar-daemon/internal/cli` | +| Service profile | Pure validation of qualified operations and placements | `services/core/internal/engine` | +| Registration | Installed factories and verified capability declarations | `apps/daemon/internal/cli` | An Environment supplies execution resources. Managed Docker, E2B and user-managed machines differ in provisioning and connection; their connected Runtime uses this @@ -57,16 +57,16 @@ model communication configuration, not Turn scheduling or native process ownersh 1. **Pin the native source.** Record the upstream package version and source revision and document the native entry point next to the adapter. -2. **Implement the adapter** in `apps/parsar-daemon/internal/agent/`: an +2. **Implement the adapter** in `apps/daemon/internal/agent/`: an `ExecutorFactory`, an `Executor` and a `Turn`. See [Required adapter interfaces](#required-adapter-interfaces). Reuse shared process, credential/configuration and local workspace helpers. -3. **Register the kind in the Runtime** in `apps/parsar-daemon/internal/cli`. +3. **Register the kind in the Runtime** in `apps/daemon/internal/cli`. See [Register the adapter](#register-the-adapter). 4. **Add the service profile and one catalog entry.** See [Add the engine to Core](#add-the-engine-to-core). 5. **Package native prerequisites.** Add a Runtime image under - `services/agents-api/deploy/` and, optionally, + `services/core/deploy/` and, optionally, [native installer participation](#native-installer-participation). 6. **Enable and select the engine** through [engine selection](#engine-selection). @@ -115,7 +115,7 @@ supplies one `Configuration` to Core's composition and Runtime's `RegisterKind`. All three Runtime entry paths validate through that declaration before native side effects: direct factory, preparation and Executor. Registry wrappers retain the declaration alongside the factory. Lifecycle and cleanup ownership remain in -[`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go). +[`agent/harness.go`](../../apps/daemon/internal/agent/harness.go). The shared wire object is `proto.HarnessConfig`. A supplied `model` must be a nonempty string, and an explicit `model_provider` @@ -156,7 +156,7 @@ ownership are in the [unified model configuration design](model-configuration-de ## Required adapter interfaces -[`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go) is the +[`agent/harness.go`](../../apps/daemon/internal/agent/harness.go) is the canonical interface entry point. Its required lifecycle is `ExecutorFactory`, `Executor`, `Turn` (including `DurableSteerer`) and `TurnSettlement`. Required methods must perform their native obligations; returning Unsupported is not an @@ -247,8 +247,8 @@ for automatic replay. Registration is static and requires a build; dynamic plugins are outside this contract. The methods live in `agent/harness.go`, and built-in adapters call them -from [`cli/agent_registration.go`](../../apps/parsar-daemon/internal/cli/agent_registration.go) -(Codex, MiniMax Code) and [`cli/claude_sdk.go`](../../apps/parsar-daemon/internal/cli/claude_sdk.go) +from [`cli/agent_registration.go`](../../apps/daemon/internal/cli/agent_registration.go) +(Codex, MiniMax Code) and [`cli/claude_sdk.go`](../../apps/daemon/internal/cli/claude_sdk.go) (Claude Code). | Order | Method | Registers | @@ -284,7 +284,7 @@ Runtime registration does not grant Core qualification; that belongs to the service profile. The runnable test-only example is -[`testdata/onboarding/main.go`](../../apps/parsar-daemon/testdata/onboarding/main.go). +[`testdata/onboarding/main.go`](../../apps/daemon/testdata/onboarding/main.go). It registers a text-only synthetic Harness, demonstrates a Session-owned Executor, fresh Turns, durable steering, cancellation and history binding, and is never shipped as a real engine. @@ -296,7 +296,7 @@ Add one entry to `internal/harnessconfig/builtin/catalog.json` with: - the public `kind` and display `label`; - the model `configuration` package under `internal/harnessconfig`; -- the `profile` constructor under `services/agents-api/internal/engine`. +- the `profile` constructor under `services/core/internal/engine`. Implement the profile constructor, then run `make generate-harness-catalog`. This generates the model configuration registry, Core profile catalog, client @@ -404,7 +404,7 @@ adapter tests must cover actual native semantics, not only method presence. | MiniMax native history binding | `mcode/session_test.go` | | Explicit refusals without native effects or fabricated results | Each adapter's `unsupported_test.go` | -These paths are relative to `apps/parsar-daemon/internal/agent`. Controlled native +These paths are relative to `apps/daemon/internal/agent`. Controlled native transport fixtures establish failure and ownership behavior; they are not live model qualification. Preserve the separate native acceptance requirements in [Harness integration](harnesses.md#acceptance-checklist). @@ -456,6 +456,6 @@ Use these adapters as implementation references after choosing a native API: | Harness | Adapter | Native transport | Runtime guide | | --- | --- | --- | --- | -| Codex | [`agent/codex`](../../apps/parsar-daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](../../services/agents-api/deploy/codex/README.md) | -| Claude Code | [`agent/claudesdk`](../../apps/parsar-daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](../../packages/claude-sdk-adapter/README.md) | [Claude Runtime](../../services/agents-api/deploy/claude/README.md) | -| MiniMax Code | [`agent/mcode`](../../apps/parsar-daemon/internal/agent/mcode) | ACP and native workspace companion | [MiniMax Code Runtime](../../services/agents-api/deploy/mcode/README.md) | +| Codex | [`agent/codex`](../../apps/daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](../../services/core/deploy/codex/README.md) | +| Claude Code | [`agent/claudesdk`](../../apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](../../packages/claude-sdk-adapter/README.md) | [Claude Runtime](../../services/core/deploy/claude/README.md) | +| MiniMax Code | [`agent/mcode`](../../apps/daemon/internal/agent/mcode) | ACP and native workspace companion | [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) | diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index a2ac5efe3..0ebc47cb7 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -58,7 +58,7 @@ contracts. | Operation | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | | Docker hosted text execution, native local tools | Qualified | Qualified | Qualified (Docker `openai_hosted`) | -| Files, immutable Artifacts, cancellation, restart/history recovery | Qualified | Qualified | Qualified on the dedicated Docker profile; see [MiniMax Code Runtime](../../services/agents-api/deploy/mcode/README.md) | +| Files, immutable Artifacts, cancellation, restart/history recovery | Qualified | Qualified | Qualified on the dedicated Docker profile; see [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) | | Public functions in `none` | Qualified | Qualified; object-root schemas; text or successful inline PNG/JPEG results | Unsupported | | Public functions alongside hosted workspace tools | Qualified | Qualified; object-root schemas and text or successful inline PNG/JPEG results | Unsupported | | HTTP MCP and static-bearer Vault credentials in `none` | Qualified | Qualified subset | Unsupported | @@ -119,7 +119,7 @@ engine is not complete public protocol compatibility. ## Common contract acceptance -The synthetic [third-harness fixture](../../apps/parsar-daemon/testdata/onboarding/main.go) +The synthetic [third-harness fixture](../../apps/daemon/testdata/onboarding/main.go) implements only the current text execution contract: cancellation, durable active input receipts and strict bound-history continuation. It has no workspace, MCP, public functions, permissions or user-choice handlers. Its registration is local @@ -145,7 +145,7 @@ and isolation obligations. These guarantees are independent of feature equality. ## Native operation acceptance Use the pinned official Python SDK, raw HTTP and real model APIs. The common -`services/agents-api/tests/official_hosted_functions_native.py` assertions exercise +`services/core/tests/official_hosted_functions_native.py` assertions exercise function success/error, native file output and public artifact bytes, same-history continuation after restart, foreign result rejection and pending-call cancellation. The operator fixture supplies only deployment/restart and model configuration; diff --git a/contracts/agents-api/model-protocol-qualification.md b/contracts/agents-api/model-protocol-qualification.md index 95c6efdd3..7ff0bfa9e 100644 --- a/contracts/agents-api/model-protocol-qualification.md +++ b/contracts/agents-api/model-protocol-qualification.md @@ -47,7 +47,7 @@ The console's three affected browser scenarios passed, including protocol choice save/edit/cancel, token limits and write-only credentials. The real entrypoints are TestNativeModelProtocolPublicExecution and -services/agents-api/tests/official_model_protocol_native.py. Private model settings +services/core/tests/official_model_protocol_native.py. Private model settings are supplied by OAC_TEST_MODEL_PROTOCOL_OPTIONS; do not commit them or print their values. The suite records only controlled checks, IDs and event counts under OAC_TEST_NATIVE_PROOF_DIR. Library and product performance evidence are separate: diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index a75521d56..4ca748764 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -33,7 +33,7 @@ Repository paths below are relative to the inspected worktree; private evidence | F | `contracts/agents-api/source-files.md:11,65`: implemented general Files workflow and recorded PostgreSQL/SDK/raw-list checks; `contracts/agents-api/environment-files.md:1,30,71`: bounded local workspace list/copy and real deployment references. D and K add real consumption/copy/retention workflows. | | K | `contracts/agents-api/environment-templates.md:94,764`: recorded fixed-SDK/raw/PostgreSQL Skill resource checks plus all-three-harness Docker reference workflows (Codex/Claude Kimi K3, MiniMax M2.7). Remote `~/.parsar/remediation/20260921/template-skill-references/`; local index `~/.parsar/remediation/20260921/template-capabilities-design/`. Covers upload, frozen template/Session resolution, native supporting files, source/template deletion, retry and cold continuation. Individual resource mutation cases not explicitly claimed by the summary remain DB-only or unspecified. | | I | `contracts/agents-api/environment-templates.md`: separate recorded initial-file (:589), env/setup/npm/Python (:621), inline-Skill (:657), system-package (:692), capability-directory, Plugin MCP (:241), composition (:327) and restricted-network (:523) qualification. Exact historical run roots are in each section. No combinatorial/full hosted parity inference. | -| O | `services/agents-api/oauth-credentials.md:144`: recorded genuine Keycloak 26.7.4 PKCE grants, real TLS MCP and Kimi execution. Codex Basic client-auth initial/refresh/restart/replacement/revocation/delete; Claude POST initial/refresh. Trusted `none` profiles only; not MiniMax, hosted, arbitrary-provider or complete error equivalence. | +| O | `services/core/oauth-credentials.md:144`: recorded genuine Keycloak 26.7.4 PKCE grants, real TLS MCP and Kimi execution. Codex Basic client-auth initial/refresh/restart/replacement/revocation/delete; Claude POST initial/refresh. Trusted `none` profiles only; not MiniMax, hosted, arbitrary-provider or complete error equivalence. | | V | [Resource selector and error qualification](resource-selector-semantics.md); private September23 `skill-version-alignment/official/` and `files-error-alignment/official-probe.json`: owned Skill/Template/Session selector observations and seven missing File/cursor requests. Preserve each probe phase and distinguish actual hosted execution from metadata-only reads. | | W | `~/.parsar/remediation/20260923/file-resource-semantics/official-files/` and `official-skills/`: 56 owned resource requests, no Sessions/models; [qualified observations and limitations](file-resource-semantics.md). | | X | [Validation error fields](official-semantics-alignment.md#validation-error-fields--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/{vaults-agents,sessions,skills-files-templates}/findings.json` VA-07/08/09/10, SES-28, SFT-20 and the September 22 Session `metadata.a` null observation. Metadata/name field errors, U+0000 local limit, malformed path IDs and Template network codes; Go handler and real-PostgreSQL route/no-write tests, no model execution. | @@ -138,7 +138,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa ## Fixed SDK and current route appendix -The local fixed-SDK source links below identify the method implementation (overloads excluded). All paths have the base `/v1` added. Source version is verified in [_version.py](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/_version.py#L2). Core registration sources: [handler.go](../../services/agents-api/internal/api/handler.go), [subagents.go](../../services/agents-api/internal/api/subagents.go), [skills.go](../../services/agents-api/internal/api/skills.go). +The local fixed-SDK source links below identify the method implementation (overloads excluded). All paths have the base `/v1` added. Source version is verified in [_version.py](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/_version.py#L2). Core registration sources: [handler.go](../../services/core/internal/api/handler.go), [subagents.go](../../services/core/internal/api/subagents.go), [skills.go](../../services/core/internal/api/skills.go). | Fixed resource source / method | HTTP operation | | --- | --- | diff --git a/contracts/agents-api/public-mcp-qualification.md b/contracts/agents-api/public-mcp-qualification.md index 6070fc0a0..83f27445b 100644 --- a/contracts/agents-api/public-mcp-qualification.md +++ b/contracts/agents-api/public-mcp-qualification.md @@ -84,7 +84,7 @@ explicitly unsupported. There is no service-network proxy or model-loop fallback Focused Core/Runtime tests, 170 Claude adapter tests and the pinned official client workflow pass. Full make check, native platform CI and independent review -results are recorded on [PR #251](https://github.com/MiniMax-AI/parsar-core/pull/251). +results are recorded on [PR #251](https://github.com/MiniMax-AI/OpenAgentCore/pull/251). The first local full run stopped because its new test database did not match the required oac_*_tests naming rule. A later run passed Go/database/adapter checks but reached an occupied browser fixture port; remaining checks use separate diff --git a/contracts/agents-api/runtime-observability-design.md b/contracts/agents-api/runtime-observability-design.md index 2a015778a..d968ab710 100644 --- a/contracts/agents-api/runtime-observability-design.md +++ b/contracts/agents-api/runtime-observability-design.md @@ -344,7 +344,7 @@ reads immediately; physical removal is incremental. ### 10.3 Backend-neutral history query boundary -`services/agents-api/internal/runtimehistory` defines the server-side query +`services/core/internal/runtimehistory` defines the server-side query contract independently from SQL, OTLP, and the public HTTP shape. Its service resolves the authenticated tenant and Session to durable Core identity before calling a Reader. Reader queries always carry tenant, Session, and diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 1060dc558..ef2e5a199 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -543,7 +543,7 @@ release patterns and canonical field order. `sandbox/deployment.go` applies thos rules in Core. The installer consumes the generated declaration in `deploy/install/node_spec.py`; do not maintain a second set of limits or patterns. Regenerate it from the repository root with -`go run ./services/agents-api/cmd/specification-contract -write`. +`go run ./services/core/cmd/specification-contract -write`. The sandbox Go tests, included in `make check`, reject a stale projection. The specification digest is SHA-256 of UTF-8 compact JSON, with `provider` first, @@ -551,7 +551,7 @@ then `resources`, then `runtime` when required by the provider. Resource and Runtime fields follow the contract declaration order. Zero optional disk fields are omitted; required fields remain present. Release identities are lowercase ASCII; the digest never hashes the incoming JSON field order or whitespace. -`internal/sandbox/testdata/deployment-contract.json` (under `services/agents-api/`) +`internal/sandbox/testdata/deployment-contract.json` (under `services/core/`) contains shared acceptance cases, exact canonical bytes and digests consumed by both Go and Python tests. Cross-language validation is required; distinct peers must not invent distinct rules. diff --git a/contracts/agents-api/structured-output.md b/contracts/agents-api/structured-output.md index 403b87ce2..b31a697fb 100644 --- a/contracts/agents-api/structured-output.md +++ b/contracts/agents-api/structured-output.md @@ -95,14 +95,14 @@ qualification limits are recorded in [the coverage note](structured-output.md). ## Acceptance `TestNativeStructuredOutputPublicExecution` and -`services/agents-api/tests/official_structured_output.py` exercise the pinned SDK, +`services/core/tests/official_structured_output.py` exercise the pinned SDK, raw HTTP, actual PostgreSQL/Worker/gateway/daemon and real model APIs. They require explicit private operator options and never supply model responses. The workflow covers a function-only random value, unchanged saved configuration, native result application receipts, ordered terminal SSE, persisted final JSON, daemon restart and same-history continuation, cancellation, text override and tenant isolation. -`services/agents-api/tests/official_hosted_structured_native.py` extends public +`services/core/tests/official_hosted_structured_native.py` extends public acceptance to an independently deployed Core, dedicated PostgreSQL and Docker Runtime using the real Kimi API. It covers initial saved configuration and inline prepared configuration, function-only random values, active input receipts, diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 6c4e84ef4..7ff504ede 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -144,7 +144,7 @@ Evidence root on `zju_a100_2`: `~/.parsar/remediation/20260922/subagent-contract/`. Public proofs are in `public-codex-kimi1`, `public-claude_sdk-2` and `public-mcode-1`; native mechanism proofs are in `native-proof`, `claude-native` and `mcode-native`. The shared script -`scripts/agents-api-subagents-acceptance.py --phase spawn-direct` validates common +`scripts/core-subagents-acceptance.py --phase spawn-direct` validates common reads; its `resources_passed` and `requested_phase_passed` fields qualify that phase. Since the visibility batch, `resources_passed` also requires every named check recorded under `visibility`. Its aggregate `passed` field additionally @@ -217,7 +217,7 @@ history ownership, cancellation, cold continuation and tenant isolation. Go store and API tests cover each row, including tenant isolation. A real-PostgreSQL HTTP test also checks creation-stream settlement with Subagent facts. TypeScript client and Core Web unit tests cover the official child Turn shape and the -root-only timeline. `scripts/agents-api-subagents-acceptance.py` records A1–A5 +root-only timeline. `scripts/core-subagents-acceptance.py` records A1–A5 as named `visibility` checks per phase, including the observed stream. Its inspect phase, run against a controlled local fixture without a model or stream, reported all six read differences on baseline main and passed on this branch. Live model acceptance and the server gate are recorded with the diff --git a/contracts/agents-api/tool-policy.md b/contracts/agents-api/tool-policy.md index 2f2085ef8..8d0ce8ae2 100644 --- a/contracts/agents-api/tool-policy.md +++ b/contracts/agents-api/tool-policy.md @@ -54,7 +54,7 @@ does not require the new capability. Search uses the existing disabled control. ## Acceptance The opt-in `TestNativeToolPolicyPublicExecution` fixture and -`services/agents-api/tests/official_tool_policy.py` exercise a real PostgreSQL +`services/core/tests/official_tool_policy.py` exercise a real PostgreSQL database, independent API, Docker daemon and native harness using the pinned official SDK with strict response validation and raw HTTP. Supply the existing native-test environment variables plus `OAC_TEST_TOOL_POLICY_ENGINE` and a private diff --git a/contracts/agents-api/v1/core_extension.go b/contracts/agents-api/v1/core_extension.go index 0da78ce94..156761009 100644 --- a/contracts/agents-api/v1/core_extension.go +++ b/contracts/agents-api/v1/core_extension.go @@ -3,7 +3,7 @@ package v1 import ( "encoding/json" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) // AgentsCore selects an existing Core harness independently of model identity. diff --git a/contracts/agents-api/v1/harness_catalog_test.go b/contracts/agents-api/v1/harness_catalog_test.go index b26bf4f17..e3f7b121f 100644 --- a/contracts/agents-api/v1/harness_catalog_test.go +++ b/contracts/agents-api/v1/harness_catalog_test.go @@ -3,7 +3,7 @@ package v1 import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) func TestRegisteredHarnessesSharePublicValidation(t *testing.T) { diff --git a/contracts/agents-api/v1/model_configuration.go b/contracts/agents-api/v1/model_configuration.go index 2d6e6507b..001285756 100644 --- a/contracts/agents-api/v1/model_configuration.go +++ b/contracts/agents-api/v1/model_configuration.go @@ -3,8 +3,8 @@ package v1 import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) // ModelConfigurationInput combines the existing provider contract with a model diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 95db49fd9..d3c5f22da 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -4,8 +4,8 @@ import ( "encoding/json" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) // ModelProviderError preserves the shared validation message while allowing Core diff --git a/contracts/agents-api/v1/saved_core_extension.go b/contracts/agents-api/v1/saved_core_extension.go index fea0cecbf..1b1353a3d 100644 --- a/contracts/agents-api/v1/saved_core_extension.go +++ b/contracts/agents-api/v1/saved_core_extension.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) // SavedAgentCoreInput carries defaults for future Sessions. The provider bundle diff --git a/deploy/install-release.sh b/deploy/install-release.sh index 8679c8b46..b174121ab 100755 --- a/deploy/install-release.sh +++ b/deploy/install-release.sh @@ -20,7 +20,7 @@ import urllib.error import urllib.parse import urllib.request -REPOSITORY = "MiniMax-AI/parsar-core" +REPOSITORY = "MiniMax-AI/OpenAgentCore" API = "https://api.github.com/repos/" + REPOSITORY ARCHIVE = re.compile(r"oac-([0-9a-f]{40})-linux-amd64\.tar\.gz") diff --git a/deploy/install/README.md b/deploy/install/README.md index 75d9607e7..6d551fd23 100644 --- a/deploy/install/README.md +++ b/deploy/install/README.md @@ -1,6 +1,6 @@ # Installer design rules -This directory holds the Core/Web installer, the `oac` command and the node installer. The self-hosted daemon installer (`oac-daemon install`) lives in `apps/parsar-daemon/internal/cli`; its rules are in [Native daemon installer](#native-daemon-installer). These are the rules to keep when you change them. Operator usage is in the [installation guide](../../docs/getting-started/install.md), the [node guide](../../docs/getting-started/nodes.md) and the [self-hosted guide](../../docs/getting-started/self-hosted.md); the version policy is in [Operations](../../docs/getting-started/operations.md#installation-version-policy). Building and publishing distributions is in the [maintainer guide](../../docs/maintainers.md). `make check-distribution` runs this directory's tests. +This directory holds the Core/Web installer, the `oac` command and the node installer. The self-hosted daemon installer (`oac-daemon install`) lives in `apps/daemon/internal/cli`; its rules are in [Native daemon installer](#native-daemon-installer). These are the rules to keep when you change them. Operator usage is in the [installation guide](../../docs/getting-started/install.md), the [node guide](../../docs/getting-started/nodes.md) and the [self-hosted guide](../../docs/getting-started/self-hosted.md); the version policy is in [Operations](../../docs/getting-started/operations.md#installation-version-policy). Building and publishing distributions is in the [maintainer guide](../../docs/maintainers.md). `make check-distribution` runs this directory's tests. | Module | Role | | --- | --- | diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index 513b9610e..0d198aae3 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -27,7 +27,7 @@ def valid_core_origin(value): """Accept exactly the origins Core's ValidateSandboxCoreURL accepts - (services/agents-api/internal/store/sandbox_deployment_setup.go), so an + (services/core/internal/store/sandbox_deployment_setup.go), so an installer value never fails Core's OAC_PUBLIC_URL check at startup.""" if not isinstance(value, str) or any(char in value for char in "?#@\\% \t\r\n"): return False diff --git a/deploy/install/test_node_spec.py b/deploy/install/test_node_spec.py index d065b69e0..ef6c7458f 100644 --- a/deploy/install/test_node_spec.py +++ b/deploy/install/test_node_spec.py @@ -113,7 +113,7 @@ def test_digest_is_independent_of_response_object_key_order(self): class SharedDeploymentContractTests(unittest.TestCase): def test_shared_acceptance_and_canonical_bytes(self): - path = Path(__file__).resolve().parents[2] / "services/agents-api/internal/sandbox/testdata/deployment-contract.json" + path = Path(__file__).resolve().parents[2] / "services/core/internal/sandbox/testdata/deployment-contract.json" for fixture in json.loads(path.read_text()): with self.subTest(name=fixture["name"]): if not fixture["valid"]: diff --git a/docs/api/README.md b/docs/api/README.md index 18b2a89ea..b6f8f6aef 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -88,7 +88,7 @@ the Core key or a Project API key. | `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | | `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node routes](../../contracts/agents-api/sandbox-deployment.md#authority-and-routes) | | `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](../../services/agents-api/README.md#user-managed-runtime-enrollment) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](../../services/core/README.md#user-managed-runtime-enrollment) | ## Contract sources diff --git a/docs/development.md b/docs/development.md index 3f209c139..b8cd29623 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,7 @@ Runtime builds have additional prerequisites in their component guides. ```sh pnpm install --frozen-lockfile python3 -m venv .venv -.venv/bin/python -m pip install -r services/agents-api/tests/requirements.txt +.venv/bin/python -m pip install -r services/core/tests/requirements.txt .venv/bin/python - <<'PYTHON' import json import subprocess @@ -55,7 +55,7 @@ relevant native qualification as well as compilation. From the repository root: ```sh -make build-agents-api +make build-core make build-daemon ``` @@ -64,7 +64,7 @@ under `~/.oac/build/daemon/` by default. `OAC_DEV_HOME` selects another build ro `OAC_DEV_CORE_BUILD_DIR` selects an absolute Core output directory. Building does not configure a database, start a deployment or qualify native execution. -Use the [service guide](../services/agents-api/README.md#build-standalone-binaries) +Use the [service guide](../services/core/README.md#build-standalone-binaries) to run the Core migrator and server with a separate development database. The [configuration appendix](configuration.md#appendix-core-environment-without-the-installer) owns standalone process settings. For a complete operator installation, use the @@ -81,16 +81,16 @@ site; `pnpm dev:docs` starts its development server. | Location | Responsibility | Read next | | --- | --- | --- | -| `services/agents-api/internal/api` | Public, administrator and machine HTTP boundaries | [API index](api/README.md) | -| `services/agents-api/internal/store` and `internal/db` | Core persistence, transactions, queries and migrations | [Service guide](../services/agents-api/README.md#database-ownership) | -| `services/agents-api/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](runtime-protocol.md) | -| `services/agents-api/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](../contracts/agents-api/harness-onboarding.md) | +| `services/core/internal/api` | Public, administrator and machine HTTP boundaries | [API index](api/README.md) | +| `services/core/internal/store` and `internal/db` | Core persistence, transactions, queries and migrations | [Service guide](../services/core/README.md#database-ownership) | +| `services/core/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](runtime-protocol.md) | +| `services/core/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](../contracts/agents-api/harness-onboarding.md) | | `internal/agentdaemon/proto` and `gateway` | Shared wire types, validators and authenticated Runtime connections | [Runtime protocol](runtime-protocol.md) | | `internal/runtimebootstrap` | Provider-to-Runtime startup input | [Runtime bootstrap](runtime-bootstrap.md) | -| `apps/parsar-daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](../contracts/agents-api/harness-onboarding.md#required-adapter-interfaces) | -| `apps/parsar-daemon/internal/agent` | Native harness adapters | [Native references](../contracts/agents-api/harness-onboarding.md#native-references) | -| `services/agents-api/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](sandbox-provider.md) | -| `services/core-console` | Console login and the server-side management proxy | [Console server](web/console-server.md) | +| `apps/daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](../contracts/agents-api/harness-onboarding.md#required-adapter-interfaces) | +| `apps/daemon/internal/agent` | Native harness adapters | [Native references](../contracts/agents-api/harness-onboarding.md#native-references) | +| `services/core/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](sandbox-provider.md) | +| `services/web` | Console login and the server-side management proxy | [Console server](web/console-server.md) | | `apps/web` and `packages/agents-client` | Console UI and typed clients | [Web guide](../apps/web/README.md) | | `deploy/install` and `scripts` | Distribution, installation and validation tools | [Maintainers](maintainers.md) | | `contracts/agents-api` | Pinned schema, local semantic contracts and qualification evidence | [Coverage ledger](../contracts/agents-api/README.md) | @@ -118,7 +118,7 @@ only helps you pick the right one. ### Add a Harness adapter Implement the shared `ExecutorFactory`, `Executor` and `Turn` interfaces in -[`agent/harness.go`](../apps/parsar-daemon/internal/agent/harness.go), register +[`agent/harness.go`](../apps/daemon/internal/agent/harness.go), register the adapter and add its profile/configuration entry to the shared catalog. Follow the numbered steps in [Harness onboarding](../contracts/agents-api/harness-onboarding.md); qualification evidence belongs in [Harness integration](../contracts/agents-api/harnesses.md). @@ -126,7 +126,7 @@ evidence belongs in [Harness integration](../contracts/agents-api/harnesses.md). ### Add a Sandbox Provider Implement the five required `SandboxProvider` operations in -[`sandbox_provider.go`](../services/agents-api/internal/sandbox/sandbox_provider.go), +[`sandbox_provider.go`](../services/core/internal/sandbox/sandbox_provider.go), register the provider kind and pass `make check-sandbox-provider-contract`. Follow the numbered steps in the [Sandbox Provider guide](sandbox-provider.md). @@ -145,7 +145,7 @@ Run checks for the affected boundary while developing. The repository | Change | Focused validation | | --- | --- | -| Core handlers, persistence or clients | `make check-agents-api` | +| Core handlers, persistence or clients | `make check-core` | | SQL queries | `make sqlc-generate`, inspect generated files, then `make check-sqlc` | | Handler annotations or API contract | `make openapi`, inspect all three namespace schemas | | Shared Runtime protocol | `make check-runtime-contract` | diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index b803a34a2..b2dd4e3e7 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -63,7 +63,7 @@ Docker and microsandbox start at the Standard size in Web's [`standard-sizes.jso **Docker** shares each node's kernel with its sandboxes, and its node service account is [root-equivalent](nodes.md#what-the-installer-sets-up). Choose it only for trusted workloads or hosts without KVM. The installer asks for confirmation (default No); without a terminal, pass `--accept-docker-risks`. -**E2B** needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud, so pass `--public-url`. Prepare the template build with the [E2B guide](../../services/agents-api/deploy/e2b/README.md), then: +**E2B** needs a public HTTPS URL that is not loopback, because E2B's sandboxes call Core from E2B's cloud, so pass `--public-url`. Prepare the template build with the [E2B guide](../../services/core/deploy/e2b/README.md), then: ```sh ./install.sh --public-url https://core.example --sandbox e2b \ diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 0042b2ec9..f4ef8d375 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -25,13 +25,13 @@ The Core host needs no KVM; nodes that run microsandbox do. ## Install ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash ``` If DNS already points to this host, pass the address to set up HTTPS during installation instead of in step 4: ```sh -curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash -s -- --public-url https://core.example +curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash -s -- --public-url https://core.example ``` The script picks the latest stable release, verifies its checksum and runs the bundled installer, which: diff --git a/docs/maintainers.md b/docs/maintainers.md index af2df5e8f..34e277bb8 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -13,7 +13,7 @@ bash scripts/prepare-release-runtimes.sh inputs="$HOME/.oac/build/release-inputs/inputs.json" export AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" export MCODE_HARNESS_BUILD_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["mcode"])' "$inputs")" -export CORE_DISTRIBUTION_RELEASE_BASE_URL=https://github.com/MiniMax-AI/parsar-core/releases/download/v1.2.3 +export CORE_DISTRIBUTION_RELEASE_BASE_URL=https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v1.2.3 make build-core-distribution ``` @@ -60,7 +60,7 @@ make build-agents-runtime docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/.oac}/build/agents-runtime" ``` -The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified native executable, its resources and `services/agents-api/deploy/codex/Dockerfile`. +The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified native executable, its resources and `services/core/deploy/codex/Dockerfile`. **Claude Code Runtime image.** Node 20 or newer and pnpm are required. @@ -92,7 +92,7 @@ The distribution combines the three Harness images into one Runtime image (`depl make build-e2b-provider ``` -Docker builds the Linux amd64 helper with the pinned CPython and Debian 12 image. The Python dependency closure, including PyInstaller, is hash-locked in `services/agents-api/tools/e2b-provider/requirements.lock`; no E2B account key is needed. Set `E2B_PROVIDER_BUILD_DIR` for another output directory and `E2B_SOURCE_REVISION` when building from an exported source tree. The output is `oac-e2b-provider-linux-amd64.tar.gz` with its `.sha256`; it extracts to `oac-e2b-provider/` with the executable, `_internal/`, `licenses/`, `requirements.lock` and `manifest.json`. The Core image and native Core use the same tree; the host needs a compatible glibc and CA certificates, not Python. +Docker builds the Linux amd64 helper with the pinned CPython and Debian 12 image. The Python dependency closure, including PyInstaller, is hash-locked in `services/core/tools/e2b-provider/requirements.lock`; no E2B account key is needed. Set `E2B_PROVIDER_BUILD_DIR` for another output directory and `E2B_SOURCE_REVISION` when building from an exported source tree. The output is `oac-e2b-provider-linux-amd64.tar.gz` with its `.sha256`; it extracts to `oac-e2b-provider/` with the executable, `_internal/`, `licenses/`, `requirements.lock` and `manifest.json`. The Core image and native Core use the same tree; the host needs a compatible glibc and CA certificates, not Python. **microsandbox helper.** Linux only, with a C compiler: @@ -107,11 +107,11 @@ The helper is written to `~/.oac/build/microsandbox-provider/oac-microsandbox-pr ### Standalone Core builds -`make build-agents-api` builds `oac-core`, `oac-core-migrate`, `oac-core-device`, `oac-core-environment-key` and `oac-node` into `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core` (`OAC_DEV_CORE_BUILD_DIR` selects another absolute directory). The build copies only the source set listed in `scripts/build-agents-api.sh` (the Core service, its contracts, the shared packages it needs and the root Go module files) into a temporary context and builds with CGO disabled, read-only modules and trimmed paths. It needs no Node, Docker or other application. When Core gains a shared dependency, add that package to the list; never copy the whole repository to make it compile. +`make build-core` builds `oac-core`, `oac-core-migrate`, `oac-core-device`, `oac-core-environment-key` and `oac-node` into `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core` (`OAC_DEV_CORE_BUILD_DIR` selects another absolute directory). The build copies only the source set listed in `scripts/build-core.sh` (the Core service, its contracts, the shared packages it needs and the root Go module files) into a temporary context and builds with CGO disabled, read-only modules and trimmed paths. It needs no Node, Docker or other application. When Core gains a shared dependency, add that package to the list; never copy the whole repository to make it compile. -`make docker-build-agents-api` builds the image `oac-core:dev` (`OAC_DEV_CORE_IMAGE` selects another name) from those five commands and the E2B helper. The base is the digest-pinned `debian:bookworm-slim` with CA certificates and the glibc runtime the helper needs; the default user is UID/GID 65532 and Core listens on `:8091`. The image is Linux amd64 only and is not pushed to a registry. Changes to the image or its build need `make check-agents-api-container` in addition to `make check`: it runs the official-client suite against the image with a read-only root filesystem and needs Linux Docker, a non-root user, and the [test database and pinned SDK](../services/agents-api/README.md#official-client-verification) of the service checks (`OAC_TEST_DATABASE_URL` naming an `oac_*_tests` database with the migrations applied, and `OAC_TEST_OFFICIAL_SDK_PYTHON`). +`make docker-build-core` builds the image `oac-core:dev` (`OAC_DEV_CORE_IMAGE` selects another name) from those five commands and the E2B helper. The base is the digest-pinned `debian:bookworm-slim` with CA certificates and the glibc runtime the helper needs; the default user is UID/GID 65532 and Core listens on `:8091`. The image is Linux amd64 only and is not pushed to a registry. Changes to the image or its build need `make check-core-container` in addition to `make check`: it runs the official-client suite against the image with a read-only root filesystem and needs Linux Docker, a non-root user, and the [test database and pinned SDK](../services/core/README.md#official-client-verification) of the service checks (`OAC_TEST_DATABASE_URL` naming an `oac_*_tests` database with the migrations applied, and `OAC_TEST_OFFICIAL_SDK_PYTHON`). -`make build-agents-api-release` packages the same five commands into `oac-core--linux-amd64.tar.gz` and its `.sha256` under `~/.oac/build/oac-core-release` (`OAC_DEV_RELEASE_DIR`). Beside `bin/`, the archive holds the [archive README](../services/agents-api/RELEASE.md), the license, `manifest.json` (commit, tree, platform, Go version, upstream protocol and binary hashes) and `SHA256SUMS`, which lists every packaged file. The build needs clean committed source and Python 3.9 or newer, and packages deterministically. It carries no configuration, credentials, Web or Runtime. Test archive changes by extracting a fresh copy and running its commands. +`make build-core-release` packages the same five commands into `oac-core--linux-amd64.tar.gz` and its `.sha256` under `~/.oac/build/oac-core-release` (`OAC_DEV_RELEASE_DIR`). Beside `bin/`, the archive holds the [archive README](../services/core/RELEASE.md), the license, `manifest.json` (commit, tree, platform, Go version, upstream protocol and binary hashes) and `SHA256SUMS`, which lists every packaged file. The build needs clean committed source and Python 3.9 or newer, and packages deterministically. It carries no configuration, credentials, Web or Runtime. Test archive changes by extracting a fresh copy and running its commands. ## Publish a version @@ -138,7 +138,7 @@ A manual run takes a full commit SHA, runs the same checks and builds, defaults ```sh revision=$(git rev-parse HEAD) -gh workflow run core-release --repo MiniMax-AI/parsar-core --ref main \ +gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ -f ref="$revision" -f offline=true -f draft_release=true ``` @@ -164,8 +164,8 @@ Changes limited to Web or to documentation outside `contracts/agents-api` do not The standalone archive and container give you Core alone: no Web, no `oac` command and no `config.json`. They suit development, testing and operators who supervise Core themselves. Core reads only its environment; the [configuration appendix](configuration.md#appendix-core-environment-without-the-installer) lists the variables. `OAC_DATABASE_URL` and `OAC_CORE_KEY_DIGESTS_FILE` are required; set `OAC_PUBLIC_URL` to the origin machines use to reach Core, or Core runs without the daemon transport. -- The [archive README](../services/agents-api/RELEASE.md) covers the standalone archive. -- The [service guide](../services/agents-api/README.md) covers building and running Core from source. +- The [archive README](../services/core/RELEASE.md) covers the standalone archive. +- The [service guide](../services/core/README.md) covers building and running Core from source. To run the container, create a private directory (mode 0700) with `api.env` (`OAC_DATABASE_URL` for a dedicated database, reachable from the container, `OAC_CORE_KEY_DIGESTS_FILE=/run/core-key-digests.json`, and `OAC_PUBLIC_URL`) and `core-key-digests.json`, a JSON array with the lowercase hex SHA-256 digest of your Core key. Keep both files mode 0600 and the Core key itself elsewhere. Run the migrations, then start Core: @@ -186,4 +186,4 @@ curl --fail http://127.0.0.1:8091/healthz `--user` lets the container read the key digest file as your non-root host user; alternatively grant UID 65532 read access and omit it. Put a TLS reverse proxy in front for remote clients. `/healthz` reports liveness only. Keep credentials out of the image. All state is in PostgreSQL, so the container needs no writable volume; stop and start it with `docker stop` and `docker start`, and never remove the database to replace it. One Core process serves each database; replicas add no availability. After startup, use the Core key with the [administrator API](../contracts/agents-api/admin-api.md) to create Projects and issue application keys. -The image also contains `oac-core-device` for an [internal execution device](../services/agents-api/README.md#internal-execution-device-connection) and `oac-core-environment-key`, the [break-glass credential command](../contracts/agents-api/environment-executor-credentials.md#break-glass-command). +The image also contains `oac-core-device` for an [internal execution device](../services/core/README.md#internal-execution-device-connection) and `oac-core-environment-key`, the [break-glass credential command](../contracts/agents-api/environment-executor-credentials.md#break-glass-command). diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md index c448f9b96..e65810f2a 100644 --- a/docs/runtime-bootstrap.md +++ b/docs/runtime-bootstrap.md @@ -51,6 +51,6 @@ bootstrap-file fallback. Both paths enter the same Runtime execution loop. ## Verification -`go test ./internal/runtimebootstrap ./apps/parsar-daemon/internal/cli` covers the +`go test ./internal/runtimebootstrap ./apps/daemon/internal/cli` covers the input contract, credential-source exclusivity and restart behavior. Provider tests verify delivery and permissions without relying on private Runtime storage. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 3690a1f77..2782464eb 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -4,7 +4,7 @@ This is the integration entry point for a Runtime that executes work for Core. The wire definitions live once in [`internal/agentdaemon/proto`](../internal/agentdaemon/proto); Core's [gateway](../internal/agentdaemon/gateway) and the reference Runtime's -[dispatcher](../apps/parsar-daemon/internal/dispatch) both use them. +[dispatcher](../apps/daemon/internal/dispatch) both use them. The [machine HTTP API](../contracts/agents-api/runtime.openapi.yaml) describes registration and connection endpoints. This document defines the meaning and ordering of the messages after connection; it does not replace the typed payloads. @@ -425,7 +425,7 @@ truth and reconciles from confirmed facts. Runtime retains cleanup ownership until native work, input receipts, interactions and child work have settled. The public Turn status is a separate, existing projection: -[`execution/delivery.go`](../services/agents-api/internal/execution/delivery.go) +[`execution/delivery.go`](../services/core/internal/execution/delivery.go) records an unsuccessful orchestration attempt as `failed`, including `delivery_unknown` after an unconfirmed send and `event_stream_incomplete` after subscription failure. A closed subscription can replace the send reason @@ -517,10 +517,10 @@ observations cannot establish a current connection. Run `make check-runtime-contract` from the repository root. It exercises the shared wire validators, gateway, transport and dispatcher, plus -[real WebSocket contract scenarios](../apps/parsar-daemon/internal/contracttest/wire_test.go) -using a controlled Harness adapter, plus the [observation-result regression](../services/agents-api/internal/execution/runtime_protocol_test.go). It requires no model credentials or external +[real WebSocket contract scenarios](../apps/daemon/internal/contracttest/wire_test.go) +using a controlled Harness adapter, plus the [observation-result regression](../services/core/internal/execution/runtime_protocol_test.go). It requires no model credentials or external sandbox. These tests are also included in `make check` through `check-go` and -`check-agents-api`. +`check-core`. The suite checks incompatible versions, preparation failure, cancellation settlement, connection loss without invented terminal events, reconnect without @@ -534,7 +534,7 @@ controlled-adapter test establishes the transport contract, not native Harness behavior, OS support, provider authentication or sandbox isolation. Update the shared types, this guide and the contract checks together when semantics change. -The reusable [Harness text assertions](../apps/parsar-daemon/internal/agent/contracttest/text.go) +The reusable [Harness text assertions](../apps/daemon/internal/agent/contracttest/text.go) accept any prepared Executor and a small fixture supplying deterministic normal, active and steering inputs. They check independent Turn streams, native owner and history continuity, durable write/application receipts, stale cancellation and diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 8de6e5cc8..ecd816afc 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -3,7 +3,7 @@ A **Sandbox Provider** supplies the outer compute (an *Environment*) that a Core Runtime daemon runs in, plus the bounded bootstrap that starts it. This guide is the single start-to-finish path for adding one. The canonical interface is -[`SandboxProvider`](../services/agents-api/internal/sandbox/sandbox_provider.go). +[`SandboxProvider`](../services/core/internal/sandbox/sandbox_provider.go). ## Before you start @@ -34,10 +34,10 @@ connectivity; see [Runtime and outer isolation](design-principles.md#runtime-and 1. **Read the contract.** Implement the five required operations and explicitly handle all extension interfaces in [Implement the interface](#implement-the-interface). -2. **Write the adapter package** under `services/agents-api/internal/sandbox/` +2. **Write the adapter package** under `services/core/internal/sandbox/` (native SDK calls, ownership checks, identity translation, private config). Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. - Out-of-process helpers live in `services/agents-api/tools/-provider`. + Out-of-process helpers live in `services/core/tools/-provider`. 3. **Register the kind** once using [Register the provider kind](#register-the-provider-kind). Registration is explicit construction, not an init-time plugin registry. @@ -181,7 +181,7 @@ Persist operation IDs and provider-returned snapshot provenance unchanged. another capture or restore. `ResumeCompute` only thaws the retained source; it must not cold-start a stopped one. Cleanup targets the exact compute incarnation and snapshot, not whichever instance currently has the same display name. See -[the lifecycle implementation](../services/agents-api/internal/execution/runtime_compute.go) +[the lifecycle implementation](../services/core/internal/execution/runtime_compute.go) and its failure tests before advertising this capability. ### Four distinct readiness facts @@ -220,7 +220,7 @@ For a new implementation: Node proxy identity and checkpoint advertisement consume this same entry. The installer projection uses those registered policies and the common field bounds in `sandbox/deployment_contract.go`; regenerate it with - `go run ./services/agents-api/cmd/specification-contract -write`. + `go run ./services/core/cmd/specification-contract -write`. 4. If new configuration fields are necessary, extend the typed `sandbox.Selection` envelope and its dedicated encrypted persistence fields, API DTO and operator client. Do not replace typed configuration with unrestricted JSON. Field codecs @@ -281,7 +281,7 @@ cleanup can verify the `Reference` and installation. ## Validate the integration Run `make check-sandbox-provider-contract` while developing. It runs the shared -[`contracttest`](../services/agents-api/internal/sandbox/contracttest) suite through +[`contracttest`](../services/core/internal/sandbox/contracttest) suite through real adapter boundaries using controlled native failures, plus existing adapter and node transport tests. The same packages are included in `make check`. New adapters should call the public failure runner with native-side fixtures, @@ -313,9 +313,9 @@ Environment provides isolation. | Kind | Adapter | Helper | Operator guide | | --- | --- | --- | --- | -| Docker (node) | [`sandbox/docker`](../services/agents-api/internal/sandbox/docker) | Node proxy in [`sandbox/node`](../services/agents-api/internal/sandbox/node) | [Nodes](getting-started/nodes.md) | -| microsandbox (node) | [`sandbox/microsandbox`](../services/agents-api/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](../services/agents-api/tools/microsandbox-provider) | [`deploy/microsandbox`](../services/agents-api/deploy/microsandbox/README.md) | -| E2B (direct) | [`sandbox/e2b`](../services/agents-api/internal/sandbox/e2b) | [`tools/e2b-provider`](../services/agents-api/tools/e2b-provider/README.md) | [`deploy/e2b`](../services/agents-api/deploy/e2b/README.md) | +| Docker (node) | [`sandbox/docker`](../services/core/internal/sandbox/docker) | Node proxy in [`sandbox/node`](../services/core/internal/sandbox/node) | [Nodes](getting-started/nodes.md) | +| microsandbox (node) | [`sandbox/microsandbox`](../services/core/internal/sandbox/microsandbox) | [`tools/microsandbox-provider`](../services/core/tools/microsandbox-provider) | [`deploy/microsandbox`](../services/core/deploy/microsandbox/README.md) | +| E2B (direct) | [`sandbox/e2b`](../services/core/internal/sandbox/e2b) | [`tools/e2b-provider`](../services/core/tools/e2b-provider/README.md) | [`deploy/e2b`](../services/core/deploy/e2b/README.md) | Provider selection and E2B setup are owned by [Sandbox deployment](../contracts/agents-api/sandbox-deployment.md). diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 499ec9996..c960c20e0 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -1,6 +1,6 @@ # Console server -The console server (`services/core-console`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. +The console server (`services/web`, the `oac-web` process) serves the built console, signs the administrator in with the Core key and forwards the signed-in browser's `/core/v1` requests to Core with that key. The browser never holds the Core key or any API key. Applications, nodes and self-hosted executors call Core directly; the console forwards none of their traffic. ## Request boundary diff --git a/example/parsar/package.json b/example/parsar/package.json index 519245348..ec182a717 100644 --- a/example/parsar/package.json +++ b/example/parsar/package.json @@ -13,7 +13,7 @@ "test:live": "node tests/live.mjs" }, "dependencies": { - "@agents-core-web/agents-client": "workspace:*", + "@oac/agents-client": "workspace:*", "@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-select": "2.3.2", "@radix-ui/react-slot": "^1.3.0", diff --git a/example/parsar/src/Composer.tsx b/example/parsar/src/Composer.tsx index 14d1cd9c2..df7325c06 100644 --- a/example/parsar/src/Composer.tsx +++ b/example/parsar/src/Composer.tsx @@ -4,7 +4,7 @@ import { AgentCoreError, createIdempotencyKey, type AgentSession, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import { ArrowUp, Square } from "lucide-react"; import { beginTiming, requestReturned } from "./lib/session-timing"; import { api } from "./lib/api"; diff --git a/example/parsar/src/ConnectMachine.tsx b/example/parsar/src/ConnectMachine.tsx index 214f25f73..3e10df2be 100644 --- a/example/parsar/src/ConnectMachine.tsx +++ b/example/parsar/src/ConnectMachine.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import type { AgentSession, SelfHostedAgentEnvironment, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import type { SessionRecord } from "./lib/product"; import { api } from "./lib/api"; import { Button } from "./components/ui/button"; diff --git a/example/parsar/src/Skills.tsx b/example/parsar/src/Skills.tsx index 97382cb09..029ba3af9 100644 --- a/example/parsar/src/Skills.tsx +++ b/example/parsar/src/Skills.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { BookOpen, Plus, Upload, Trash2 } from "lucide-react"; -import type { Skill } from "@agents-core-web/agents-client"; +import type { Skill } from "@oac/agents-client"; import { api, readHistory } from "./lib/api"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; diff --git a/example/parsar/src/components/Activity.tsx b/example/parsar/src/components/Activity.tsx index 2873e115e..3468aa685 100644 --- a/example/parsar/src/components/Activity.tsx +++ b/example/parsar/src/components/Activity.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Check, Copy, Terminal, ChevronRight } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; -import type { SessionItem } from "@agents-core-web/agents-client"; +import type { SessionItem } from "@oac/agents-client"; import { Button } from "./ui/button"; export function Thinking() { diff --git a/example/parsar/src/lib/api.ts b/example/parsar/src/lib/api.ts index 1d4bcc96f..3f557444d 100644 --- a/example/parsar/src/lib/api.ts +++ b/example/parsar/src/lib/api.ts @@ -4,7 +4,7 @@ import { type AgentTurn, type ListPage, type PageOptions, -} from "@agents-core-web/agents-client"; +} from "@oac/agents-client"; import type { StatusKind } from "../components/ui/status-icon"; export const api = new OpenAIAgentsClient(); diff --git a/example/parsar/src/lib/live-session.ts b/example/parsar/src/lib/live-session.ts index c2e6dce48..9a0c65630 100644 --- a/example/parsar/src/lib/live-session.ts +++ b/example/parsar/src/lib/live-session.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { SessionEvent, SessionItem } from "@agents-core-web/agents-client"; +import type { SessionEvent, SessionItem } from "@oac/agents-client"; import { firstTextArrived } from "./session-timing"; import { api, readHistory } from "./api"; diff --git a/example/parsar/src/lib/product.ts b/example/parsar/src/lib/product.ts index ec5001749..2e8f251ba 100644 --- a/example/parsar/src/lib/product.ts +++ b/example/parsar/src/lib/product.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import type { CoreHarnessKind } from "@agents-core-web/agents-client"; +import type { CoreHarnessKind } from "@oac/agents-client"; export interface NamedResource { id: string; name: string; diff --git a/go.mod b/go.mod index 19c01eb63..9146030d1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/MiniMax-AI-Dev/parsar +module github.com/MiniMax-AI/OpenAgentCore go 1.26.8 diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go index ce8c24c80..785de407f 100644 --- a/internal/agentcapabilities/install.go +++ b/internal/agentcapabilities/install.go @@ -7,9 +7,9 @@ import ( "strconv" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) func InstallSkill(root *os.Root, archive []byte, metadata agentskill.Metadata) error { diff --git a/internal/agentcapabilities/install_test.go b/internal/agentcapabilities/install_test.go index 2407e100f..2db6f8e7f 100644 --- a/internal/agentcapabilities/install_test.go +++ b/internal/agentcapabilities/install_test.go @@ -8,9 +8,9 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) func TestInstalledCapabilitiesPreserveSourcesWithoutRescanning(t *testing.T) { diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 5b3da3129..555f93afb 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "io/fs" "os" "strings" @@ -14,8 +14,8 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) const Directory = "/environment/initialization/capabilities" diff --git a/internal/agentcapabilities/selection_test.go b/internal/agentcapabilities/selection_test.go index 5c5dfbd47..633c5d250 100644 --- a/internal/agentcapabilities/selection_test.go +++ b/internal/agentcapabilities/selection_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) var testIdentity = Identity{ diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/tree.go index 793261937..8260e000e 100644 --- a/internal/agentcapabilities/tree.go +++ b/internal/agentcapabilities/tree.go @@ -7,8 +7,8 @@ import ( "path" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) // ReadTree stays inside an already-owned root and rejects aliases/special files. diff --git a/internal/agentdaemon/gateway/auth.go b/internal/agentdaemon/gateway/auth.go index 9f0a03c23..b6f02291c 100644 --- a/internal/agentdaemon/gateway/auth.go +++ b/internal/agentdaemon/gateway/auth.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" ) // RuntimeTypeAgentDaemon is the value runtimes.type takes for rows diff --git a/internal/agentdaemon/gateway/auth_test.go b/internal/agentdaemon/gateway/auth_test.go index 9cfadf322..60274b87a 100644 --- a/internal/agentdaemon/gateway/auth_test.go +++ b/internal/agentdaemon/gateway/auth_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" ) type stubRuntimeStore struct { diff --git a/internal/agentdaemon/gateway/bootstrap_url_test.go b/internal/agentdaemon/gateway/bootstrap_url_test.go index 743c584b5..fcbab730e 100644 --- a/internal/agentdaemon/gateway/bootstrap_url_test.go +++ b/internal/agentdaemon/gateway/bootstrap_url_test.go @@ -1,7 +1,7 @@ package gateway import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "net/http" "net/http/httptest" "strings" diff --git a/internal/agentdaemon/gateway/cancellation.go b/internal/agentdaemon/gateway/cancellation.go index 83937c0bb..ae62ff1f7 100644 --- a/internal/agentdaemon/gateway/cancellation.go +++ b/internal/agentdaemon/gateway/cancellation.go @@ -6,8 +6,8 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // ArchivedCancellationStore is deliberately separate from RuntimeStore: a diff --git a/internal/agentdaemon/gateway/cancellation_test.go b/internal/agentdaemon/gateway/cancellation_test.go index 9b8c5aa61..ed201ebc1 100644 --- a/internal/agentdaemon/gateway/cancellation_test.go +++ b/internal/agentdaemon/gateway/cancellation_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type receiptStore struct { diff --git a/internal/agentdaemon/gateway/capabilities_test.go b/internal/agentdaemon/gateway/capabilities_test.go index da09e2af7..78b5a3b23 100644 --- a/internal/agentdaemon/gateway/capabilities_test.go +++ b/internal/agentdaemon/gateway/capabilities_test.go @@ -1,9 +1,9 @@ package gateway import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "reflect" "testing" ) diff --git a/internal/agentdaemon/gateway/chunk_exchange.go b/internal/agentdaemon/gateway/chunk_exchange.go index b7bb2058e..5751ce474 100644 --- a/internal/agentdaemon/gateway/chunk_exchange.go +++ b/internal/agentdaemon/gateway/chunk_exchange.go @@ -3,7 +3,7 @@ package gateway import ( "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // exchangeChunkFrame sends once and waits for the owning transfer's next receipt. diff --git a/internal/agentdaemon/gateway/functions_test.go b/internal/agentdaemon/gateway/functions_test.go index c38022db7..f4fca9933 100644 --- a/internal/agentdaemon/gateway/functions_test.go +++ b/internal/agentdaemon/gateway/functions_test.go @@ -1,8 +1,8 @@ package gateway import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "testing" ) diff --git a/internal/agentdaemon/gateway/handler.go b/internal/agentdaemon/gateway/handler.go index 40a47a30a..ca1b4ff81 100644 --- a/internal/agentdaemon/gateway/handler.go +++ b/internal/agentdaemon/gateway/handler.go @@ -10,8 +10,8 @@ import ( "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // HeartbeatTouch is the persistence interface the gateway uses to diff --git a/internal/agentdaemon/gateway/handler_test.go b/internal/agentdaemon/gateway/handler_test.go index cec48857b..a9d9678c5 100644 --- a/internal/agentdaemon/gateway/handler_test.go +++ b/internal/agentdaemon/gateway/handler_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/gorilla/websocket" ) diff --git a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go index 72035cc5f..911769470 100644 --- a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go +++ b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/mcp_test.go b/internal/agentdaemon/gateway/mcp_test.go index 975b658fa..dbdf0f6d0 100644 --- a/internal/agentdaemon/gateway/mcp_test.go +++ b/internal/agentdaemon/gateway/mcp_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestMCPHTTPBearerCapabilitySurvivesHeartbeatMapping(t *testing.T) { diff --git a/internal/agentdaemon/gateway/owner.go b/internal/agentdaemon/gateway/owner.go index de85f864a..eb32b1ad7 100644 --- a/internal/agentdaemon/gateway/owner.go +++ b/internal/agentdaemon/gateway/owner.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" ) const defaultOwnerLeaseTTL = 90 * time.Second diff --git a/internal/agentdaemon/gateway/owner_test.go b/internal/agentdaemon/gateway/owner_test.go index a55e1c6d3..60bc49c64 100644 --- a/internal/agentdaemon/gateway/owner_test.go +++ b/internal/agentdaemon/gateway/owner_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type fakeOwnerStore struct { diff --git a/internal/agentdaemon/gateway/preparation.go b/internal/agentdaemon/gateway/preparation.go index 286624fc2..286e5759c 100644 --- a/internal/agentdaemon/gateway/preparation.go +++ b/internal/agentdaemon/gateway/preparation.go @@ -3,7 +3,7 @@ package gateway import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) type preparationSubscription struct { diff --git a/internal/agentdaemon/gateway/preparation_test.go b/internal/agentdaemon/gateway/preparation_test.go index b9d3ffb87..e9bdeef28 100644 --- a/internal/agentdaemon/gateway/preparation_test.go +++ b/internal/agentdaemon/gateway/preparation_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestPreparationSubscriptionHasNoRunIdentityAndOrdersRevisions(t *testing.T) { diff --git a/internal/agentdaemon/gateway/retired_provider_configuration_test.go b/internal/agentdaemon/gateway/retired_provider_configuration_test.go index f104f98e4..5362a5de8 100644 --- a/internal/agentdaemon/gateway/retired_provider_configuration_test.go +++ b/internal/agentdaemon/gateway/retired_provider_configuration_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestRetiredProviderConfigurationDoesNotAffectHeartbeatAdmission(t *testing.T) { diff --git a/internal/agentdaemon/gateway/runtime_prepare.go b/internal/agentdaemon/gateway/runtime_prepare.go index 0a5076387..8056a87be 100644 --- a/internal/agentdaemon/gateway/runtime_prepare.go +++ b/internal/agentdaemon/gateway/runtime_prepare.go @@ -8,7 +8,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/runtime_prepare_test.go b/internal/agentdaemon/gateway/runtime_prepare_test.go index 223d226a3..1955f34d7 100644 --- a/internal/agentdaemon/gateway/runtime_prepare_test.go +++ b/internal/agentdaemon/gateway/runtime_prepare_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "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" ) diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index 604ddd5be..118d8fec1 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -12,9 +12,9 @@ import ( "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // Tunables. Package-level so tests can override via small helpers diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go index 3026ec85a..8fa921a0e 100644 --- a/internal/agentdaemon/gateway/session_test.go +++ b/internal/agentdaemon/gateway/session_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) // fakeConn is the WSConn implementation used by session + registry diff --git a/internal/agentdaemon/gateway/subscription.go b/internal/agentdaemon/gateway/subscription.go index 4dd79189e..dd5ac5ffe 100644 --- a/internal/agentdaemon/gateway/subscription.go +++ b/internal/agentdaemon/gateway/subscription.go @@ -5,7 +5,7 @@ import ( "fmt" "sync" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) var ErrSubscriberOverflow = errors.New("execution subscriber buffer overflow") diff --git a/internal/agentdaemon/gateway/subscription_test.go b/internal/agentdaemon/gateway/subscription_test.go index 3c79343ae..972443cad 100644 --- a/internal/agentdaemon/gateway/subscription_test.go +++ b/internal/agentdaemon/gateway/subscription_test.go @@ -5,7 +5,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestDurableSubscriptionOverflowIsExplicitAndIsolated(t *testing.T) { diff --git a/internal/agentdaemon/gateway/suspension.go b/internal/agentdaemon/gateway/suspension.go index ab507cd00..7474673a9 100644 --- a/internal/agentdaemon/gateway/suspension.go +++ b/internal/agentdaemon/gateway/suspension.go @@ -3,7 +3,7 @@ package gateway import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/suspension_test.go b/internal/agentdaemon/gateway/suspension_test.go index c9e8b0f53..df49708bc 100644 --- a/internal/agentdaemon/gateway/suspension_test.go +++ b/internal/agentdaemon/gateway/suspension_test.go @@ -3,7 +3,7 @@ package gateway import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "testing" ) diff --git a/internal/agentdaemon/gateway/workspace_directory_test.go b/internal/agentdaemon/gateway/workspace_directory_test.go index edea31d2c..7be0ee409 100644 --- a/internal/agentdaemon/gateway/workspace_directory_test.go +++ b/internal/agentdaemon/gateway/workspace_directory_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestWorkspaceDirectorySharesReadCorrelationAndFrameBound(t *testing.T) { diff --git a/internal/agentdaemon/gateway/workspace_export.go b/internal/agentdaemon/gateway/workspace_export.go index 8f6e5d639..7b8b373de 100644 --- a/internal/agentdaemon/gateway/workspace_export.go +++ b/internal/agentdaemon/gateway/workspace_export.go @@ -6,7 +6,7 @@ import ( "io" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/workspace_export_test.go b/internal/agentdaemon/gateway/workspace_export_test.go index 73a289578..f769f7427 100644 --- a/internal/agentdaemon/gateway/workspace_export_test.go +++ b/internal/agentdaemon/gateway/workspace_export_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func exportReply(t *testing.T, s *Session, id string, result proto.WorkspaceExportResultPayload) { diff --git a/internal/agentdaemon/gateway/workspace_read.go b/internal/agentdaemon/gateway/workspace_read.go index 6724b16ce..739c61687 100644 --- a/internal/agentdaemon/gateway/workspace_read.go +++ b/internal/agentdaemon/gateway/workspace_read.go @@ -5,7 +5,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/workspace_read_test.go b/internal/agentdaemon/gateway/workspace_read_test.go index 4c9b912f7..e212c5b97 100644 --- a/internal/agentdaemon/gateway/workspace_read_test.go +++ b/internal/agentdaemon/gateway/workspace_read_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func workspaceReadRequest() proto.WorkspaceReadPayload { diff --git a/internal/agentdaemon/gateway/workspace_write.go b/internal/agentdaemon/gateway/workspace_write.go index b2ee6799f..050e0014d 100644 --- a/internal/agentdaemon/gateway/workspace_write.go +++ b/internal/agentdaemon/gateway/workspace_write.go @@ -7,7 +7,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/gateway/workspace_write_test.go b/internal/agentdaemon/gateway/workspace_write_test.go index 3f7c90af3..2f63ab826 100644 --- a/internal/agentdaemon/gateway/workspace_write_test.go +++ b/internal/agentdaemon/gateway/workspace_write_test.go @@ -8,7 +8,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/proto/capabilities_test.go b/internal/agentdaemon/proto/capabilities_test.go index 707c2c887..c0e5ab6c2 100644 --- a/internal/agentdaemon/proto/capabilities_test.go +++ b/internal/agentdaemon/proto/capabilities_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestCapabilityDeclarationIsCompleteOnBothSides(t *testing.T) { diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 5507b0f18..041a62115 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -1,8 +1,8 @@ package proto import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) // LocalEnvironment names a frozen workspace selection. Runtime must verify it diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 14613fb6c..bb41594c7 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -1,7 +1,7 @@ package proto // This package lives at the repo-root module so both the server-side -// gateway/connector AND apps/parsar-daemon can import it. That rules out +// gateway/connector AND apps/daemon can import it. That rules out // importing server/internal/... (Go's internal-package rule), so wire // types like Usage are declared here in full rather than imported from // store.UsageInput. The connector layer translates at the boundary; diff --git a/internal/agentdaemon/proto/prototest/capabilities.go b/internal/agentdaemon/proto/prototest/capabilities.go index baf5c8a25..8028d9b6c 100644 --- a/internal/agentdaemon/proto/prototest/capabilities.go +++ b/internal/agentdaemon/proto/prototest/capabilities.go @@ -2,7 +2,7 @@ package prototest import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "reflect" ) diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index c5e17b0f9..c4b78e3fc 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -7,9 +7,9 @@ import ( "strings" "unicode/utf8" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" "github.com/google/uuid" ) diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index 51efa53cc..d7cb3359b 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" "github.com/google/uuid" ) diff --git a/internal/agentplugin/bundle.go b/internal/agentplugin/bundle.go index f0a2a901d..22cb371fe 100644 --- a/internal/agentplugin/bundle.go +++ b/internal/agentplugin/bundle.go @@ -10,8 +10,8 @@ import ( "sort" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) var ErrInvalid = errors.New("invalid or unsupported Plugin bundle") diff --git a/internal/agentskill/bundle.go b/internal/agentskill/bundle.go index 758595ecd..4ebb70ae2 100644 --- a/internal/agentskill/bundle.go +++ b/internal/agentskill/bundle.go @@ -10,7 +10,7 @@ import ( "gopkg.in/yaml.v3" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" ) const ( diff --git a/internal/harnessconfig/builtin/native_test.go b/internal/harnessconfig/builtin/native_test.go index 39ad11d30..b17070962 100644 --- a/internal/harnessconfig/builtin/native_test.go +++ b/internal/harnessconfig/builtin/native_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) func TestNativeModelParameters(t *testing.T) { diff --git a/internal/harnessconfig/builtin/registry.go b/internal/harnessconfig/builtin/registry.go index 197931649..6f714b39d 100644 --- a/internal/harnessconfig/builtin/registry.go +++ b/internal/harnessconfig/builtin/registry.go @@ -2,10 +2,10 @@ package builtin import ( - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/codex" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/mcode" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) var registry = harnessconfig.NewRegistry(map[string]harnessconfig.Configuration{ diff --git a/internal/harnessconfig/claudesdk/configuration.go b/internal/harnessconfig/claudesdk/configuration.go index f7635a575..4b3608162 100644 --- a/internal/harnessconfig/claudesdk/configuration.go +++ b/internal/harnessconfig/claudesdk/configuration.go @@ -1,7 +1,7 @@ // Package claudesdk owns the qualified Claude SDK provider declaration. package claudesdk -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { return harnessconfig.Configuration{ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ diff --git a/internal/harnessconfig/claudesdk/native.go b/internal/harnessconfig/claudesdk/native.go index 9b46b1fb5..9606efd5d 100644 --- a/internal/harnessconfig/claudesdk/native.go +++ b/internal/harnessconfig/claudesdk/native.go @@ -1,7 +1,7 @@ package claudesdk import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "math" ) diff --git a/internal/harnessconfig/codex/configuration.go b/internal/harnessconfig/codex/configuration.go index 1e05765f8..1d74c449f 100644 --- a/internal/harnessconfig/codex/configuration.go +++ b/internal/harnessconfig/codex/configuration.go @@ -1,7 +1,7 @@ // Package codex owns the qualified Codex provider configuration declaration. package codex -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { return harnessconfig.Configuration{ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ diff --git a/internal/harnessconfig/codex/native.go b/internal/harnessconfig/codex/native.go index 1e40e3fa7..2f87051a3 100644 --- a/internal/harnessconfig/codex/native.go +++ b/internal/harnessconfig/codex/native.go @@ -1,6 +1,6 @@ package codex -import "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" func validateNativeConfig(config proto.HarnessConfig) bool { for key, value := range config { diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 0bc82be01..53c9365f3 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -34,8 +34,8 @@ import ( "slices" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/modelprovider" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) // Provider is deliberately limited to configuration compatibility. Core owns diff --git a/internal/harnessconfig/mcode/configuration.go b/internal/harnessconfig/mcode/configuration.go index 1032ea2ff..a188579ad 100644 --- a/internal/harnessconfig/mcode/configuration.go +++ b/internal/harnessconfig/mcode/configuration.go @@ -1,7 +1,7 @@ // Package mcode owns the qualified MiniMax Code provider declaration. package mcode -import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { return harnessconfig.Configuration{Providers: []harnessconfig.Provider{ diff --git a/internal/harnessconfig/native.go b/internal/harnessconfig/native.go index d0d2746e2..fa9162823 100644 --- a/internal/harnessconfig/native.go +++ b/internal/harnessconfig/native.go @@ -6,7 +6,7 @@ import ( "errors" "io" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // ErrHarnessConfig deliberately excludes caller-controlled keys and values. diff --git a/internal/harnessconfig/provider.go b/internal/harnessconfig/provider.go index aa02abe6a..919809935 100644 --- a/internal/harnessconfig/provider.go +++ b/internal/harnessconfig/provider.go @@ -1,6 +1,6 @@ package harnessconfig -import "github.com/MiniMax-AI-Dev/parsar/internal/modelprovider" +import "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" func (c Configuration) AcceptsHarnessConfig() bool { return c.ValidateNativeConfig != nil } diff --git a/internal/runtimecrypto/cmd/emit-fixture/main.go b/internal/runtimecrypto/cmd/emit-fixture/main.go index bfacfa768..ceefeb30d 100644 --- a/internal/runtimecrypto/cmd/emit-fixture/main.go +++ b/internal/runtimecrypto/cmd/emit-fixture/main.go @@ -20,7 +20,7 @@ import ( "fmt" "os" - runtimecrypto "github.com/MiniMax-AI-Dev/parsar/internal/runtimecrypto" + runtimecrypto "github.com/MiniMax-AI/OpenAgentCore/internal/runtimecrypto" "golang.org/x/crypto/nacl/box" ) diff --git a/package.json b/package.json index da7343977..4dd082e71 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "parsar-core", + "name": "openagentcore", "private": true, "packageManager": "pnpm@10.30.3", "scripts": { - "dev:web": "pnpm --filter @agents-core-web/web dev", - "build:web": "pnpm --filter @agents-core-web/web build", + "dev:web": "pnpm --filter @oac/web dev", + "build:web": "pnpm --filter @oac/web build", "typecheck": "pnpm -r --if-present typecheck", - "test": "pnpm --filter @parsar/claude-sdk-adapter test && pnpm test:core-doctor && pnpm test:web", + "test": "pnpm --filter @oac/claude-sdk-adapter test && pnpm test:core-doctor && pnpm test:web", "test:core-doctor": "node --test scripts/core-doctor.test.mjs", - "test:web": "pnpm --filter @agents-core-web/agents-client test && pnpm --filter @agents-core-web/web test", + "test:web": "pnpm --filter @oac/agents-client test && pnpm --filter @oac/web test", "test:web:acceptance": "playwright test", "dev:docs": "pnpm --dir apps/docs dev", "build:docs": "pnpm --dir apps/docs build", diff --git a/packages/agents-client/README.md b/packages/agents-client/README.md index cb71a0b52..4f81629af 100644 --- a/packages/agents-client/README.md +++ b/packages/agents-client/README.md @@ -2,7 +2,7 @@ This package holds the typed clients that OpenAgentCore code uses to call Core: -- a TypeScript client, `@agents-core-web/agents-client` (`src/index.ts`), for the Agents API (`/v1`) and the Core API (`/core/v1`). The console (`apps/web`) and the example application under `example/` use it; it is a private workspace package; +- a TypeScript client, `@oac/agents-client` (`src/index.ts`), for the Agents API (`/v1`) and the Core API (`/core/v1`). The console (`apps/web`) and the example application under `example/` use it; it is a private workspace package; - a Go client, `v1`, that configures the official openai-go SDK for the Agents API. [API namespaces and credentials](../../docs/api/README.md) explains which credential each namespace takes. @@ -31,7 +31,7 @@ Behavior shared by the clients: A saved Agent can carry a harness, native harness parameters and a complete model provider. Keep the provider key in private application configuration: ```ts -import { OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import { OpenAIAgentsClient } from "@oac/agents-client"; // apiBaseURL is the installation's API base URL, ending in /v1. const client = new OpenAIAgentsClient({ baseUrl: apiBaseURL, token: projectAPIKey }); @@ -67,7 +67,7 @@ Reads return `ModelProviderView`, which has `api_key_configured` and never `api_ With the Core key, `AdminClient` reads the configuration a Session froze at creation and sets each harness's deployment default: ```ts -import { AdminClient } from "@agents-core-web/agents-client"; +import { AdminClient } from "@oac/agents-client"; // On the Core host; keep the Core key out of application code. const admin = new AdminClient({ baseUrl: "http://127.0.0.1:8091/core/v1", adminToken: coreKey }); @@ -92,8 +92,8 @@ console.log(defaults.model, defaults.harness_config, defaults.model_provider.api ### Checks ```sh -pnpm --filter @agents-core-web/agents-client typecheck -pnpm --filter @agents-core-web/agents-client test +pnpm --filter @oac/agents-client typecheck +pnpm --filter @oac/agents-client test ``` `pnpm test:web` and `make check-web` include them. @@ -104,7 +104,7 @@ pnpm --filter @agents-core-web/agents-client test ```go import ( - agentsclient "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + agentsclient "github.com/MiniMax-AI/OpenAgentCore/packages/agents-client/v1" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" ) @@ -135,4 +135,4 @@ session, err := sessions.New(ctx, openai.BetaAgentSessionNewParams{ The SDK has more methods than Core supports. The [Agents API contract](../../contracts/agents-api/README.md) lists the implemented routes; other methods receive explicit errors. -`make check-agents-api` runs the Go client's tests. The real-service harness, `services/agents-api/tests/official_client.py`, also runs `TestService` against a Core with fresh Projects and a dedicated PostgreSQL database, and checks the Go-created Sessions through the official Python SDK. It calls no model. +`make check-core` runs the Go client's tests. The real-service harness, `services/core/tests/official_client.py`, also runs `TestService` against a Core with fresh Projects and a dedicated PostgreSQL database, and checks the Go-created Sessions through the official Python SDK. It calls no model. diff --git a/packages/agents-client/package.json b/packages/agents-client/package.json index 5953641e8..9df29dd95 100644 --- a/packages/agents-client/package.json +++ b/packages/agents-client/package.json @@ -1,5 +1,5 @@ { - "name": "@agents-core-web/agents-client", + "name": "@oac/agents-client", "version": "0.1.0", "private": true, "type": "module", diff --git a/packages/agents-client/v1/client_test.go b/packages/agents-client/v1/client_test.go index 0c798dbd9..8d6b841ad 100644 --- a/packages/agents-client/v1/client_test.go +++ b/packages/agents-client/v1/client_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - client "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + client "github.com/MiniMax-AI/OpenAgentCore/packages/agents-client/v1" "github.com/openai/openai-go/v3" ) diff --git a/packages/agents-client/v1/service_test.go b/packages/agents-client/v1/service_test.go index 6acae5e74..7287de4ec 100644 --- a/packages/agents-client/v1/service_test.go +++ b/packages/agents-client/v1/service_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - client "github.com/MiniMax-AI-Dev/parsar/packages/agents-client/v1" + client "github.com/MiniMax-AI/OpenAgentCore/packages/agents-client/v1" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" ) @@ -19,7 +19,7 @@ import ( func TestService(t *testing.T) { base, key, otherKey := os.Getenv("OAC_TEST_CLIENT_BASE_URL"), os.Getenv("OAC_TEST_CLIENT_KEY"), os.Getenv("OAC_TEST_CLIENT_OTHER_KEY") if base == "" && key == "" && otherKey == "" { - t.Skip("real service test is run by services/agents-api/tests/official_client.py") + t.Skip("real service test is run by services/core/tests/official_client.py") } if base == "" || key == "" || otherKey == "" { t.Fatal("all real service test settings are required") diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index b67233dd4..693935775 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -1,6 +1,6 @@ # Claude SDK adapter -This package translates the pinned native Claude Agent SDK into OpenAgentCore's common Executor and Turn lifecycle. It owns the private TypeScript bridge and the native SDK configuration. The [Go adapter](../../apps/parsar-daemon/internal/agent/claudesdk) owns the bridge subprocess and translates bridge frames into the shared Runtime protocol. +This package translates the pinned native Claude Agent SDK into OpenAgentCore's common Executor and Turn lifecycle. It owns the private TypeScript bridge and the native SDK configuration. The [Go adapter](../../apps/daemon/internal/agent/claudesdk) owns the bridge subprocess and translates bridge frames into the shared Runtime protocol. [Harness onboarding](../../contracts/agents-api/harness-onboarding.md) defines the shared interfaces, registration and acceptance. Public qualification belongs to [the harness contract](../../contracts/agents-api/harnesses.md) and its linked operation contracts; local readiness cannot expand it. @@ -10,11 +10,11 @@ From the repository root, install the pinned workspace dependencies and run: ```sh pnpm install --frozen-lockfile -pnpm --filter @parsar/claude-sdk-adapter test +pnpm --filter @oac/claude-sdk-adapter test make check-claude-sdk ``` -The package test compiles TypeScript before running its tests. The Make target also builds and checks the relocatable Runtime artifact. Changes to native execution require real-provider acceptance through Core and Runtime, including continuation and cancellation, followed by the repository's required `make check`. Use [the deployment guide](../../services/agents-api/deploy/claude/README.md) for the qualified environment and Runtime build. +The package test compiles TypeScript before running its tests. The Make target also builds and checks the relocatable Runtime artifact. Changes to native execution require real-provider acceptance through Core and Runtime, including continuation and cancellation, followed by the repository's required `make check`. Use [the deployment guide](../../services/core/deploy/claude/README.md) for the qualified environment and Runtime build. ## Bridge and native lifecycle @@ -52,7 +52,7 @@ With `ObserveToolObservations`, private workspace execution requires the package ### HTTP MCP -The private adapter accepts typed anonymous HTTP and static-bearer HTTPS MCP declarations on the trusted `environment:none` harness host. The packaged readiness report must include `mcp_http_tools`; discovery advertises that feature only when present, and execution rechecks the installed bundle before dispatching an MCP request. An unchanged SDK version alone cannot qualify an older bridge. Authenticated private requests also require the packaged `mcp_http_bearer_auth` feature at discovery and dispatch. The daemon generates a separate environment reference for each server and launch; only those references enter the bridge request and native SDK configuration. The native HTTP client expands them from its owned process environment. Literal bearers must never enter SDK MCP headers because that configuration enters argv. Readiness probes receive no per-request bearer environment. Token validation is shared with the Codex adapter; credential storage remains an opaque-string contract. Public MCP admission, Vault credential selection and their failure rules belong to [public MCP connection origin](../../contracts/agents-api/environments.md#public-mcp-connection-origin) and [HTTP MCP execution](../../services/agents-api/README.md#http-mcp-execution). +The private adapter accepts typed anonymous HTTP and static-bearer HTTPS MCP declarations on the trusted `environment:none` harness host. The packaged readiness report must include `mcp_http_tools`; discovery advertises that feature only when present, and execution rechecks the installed bundle before dispatching an MCP request. An unchanged SDK version alone cannot qualify an older bridge. Authenticated private requests also require the packaged `mcp_http_bearer_auth` feature at discovery and dispatch. The daemon generates a separate environment reference for each server and launch; only those references enter the bridge request and native SDK configuration. The native HTTP client expands them from its owned process environment. Literal bearers must never enter SDK MCP headers because that configuration enters argv. Readiness probes receive no per-request bearer environment. Token validation is shared with the Codex adapter; credential storage remains an opaque-string contract. Public MCP admission, Vault credential selection and their failure rules belong to [public MCP connection origin](../../contracts/agents-api/environments.md#public-mcp-connection-origin) and [HTTP MCP execution](../../services/core/README.md#http-mcp-execution). MCP queries use the SDK's main-thread Agent definition to restrict model-visible tools, in addition to empty built-ins, strict MCP configuration, empty setting sources and default-deny permissions. Permission allowlists alone do not restrict the native model inventory. Null selects all tools from a declared server; an empty list selects none. Host functions compose with those selections. Native server status supplies original tool identities; map their normalized native aliases while preserving the original names in observations. Native status deduplicates aliases, so it does not prove a complete original server inventory. A native PreToolUse hook waits for inventory verification before admitting root calls and denies unverified, mismatched or cancelled calls. The native Agent restriction controls model-visible tools; inventory verification is not a barrier before the model request. Anonymous HTTP declarations explicitly set an empty Authorization header to disable native OAuth and automatic credential injection. Preserve that header; do not erase native history or credentials to enforce this boundary. Servers that reject a blank Authorization header, normalized name collisions and inventory changes during a query require separate validation; this profile covers static inventories. Private SDK status/control objects can contain expanded authentication headers. Read only connection and tool identity fields; never retain, log or publish raw status/configuration or control responses. Diagnostic projections must whitelist safe fields; filtering actual model or tool output does not fix a leak. The adapter profile requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote environments. Required startup is separately qualified by `mcp_http_required`. All HTTP MCP queries use native SDK startup and an empty input iterator to confirm initialization hooks. Required declarations additionally check connected server status before the initial prompt is released exactly once. Pending, failed, missing or ambiguous required status rejects before input; native startup timeouts are retained without an adapter retry loop. Normal system/init still verifies Session identity and the complete inventory before input readiness/tool authority. Optional servers keep their inventory checks without a pre-input connection requirement. A Runtime must advertise the concrete required-initialization capability; there is no fallback to another execution path. diff --git a/packages/claude-sdk-adapter/package.json b/packages/claude-sdk-adapter/package.json index 805df9c93..1404ec440 100644 --- a/packages/claude-sdk-adapter/package.json +++ b/packages/claude-sdk-adapter/package.json @@ -1,5 +1,5 @@ { - "name": "@parsar/claude-sdk-adapter", + "name": "@oac/claude-sdk-adapter", "private": true, "version": "0.0.0", "type": "module", diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 00c23b067..5b468ce7c 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -1,6 +1,6 @@ # MiniMax Code workspace bridge -This companion package lets the daemon run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The tools run as the daemon's user with ordinary permissions; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/agents-api/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. +This companion package lets the daemon run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The tools run as the daemon's user with ordinary permissions; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. One patch script (`patch-native.mjs`) makes three edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. diff --git a/packages/mcode-harness/package-lock.json b/packages/mcode-harness/package-lock.json index 712c13f46..24741c0f3 100644 --- a/packages/mcode-harness/package-lock.json +++ b/packages/mcode-harness/package-lock.json @@ -1,10 +1,10 @@ { - "name": "parsar-mcode-harness", + "name": "@oac/mcode-harness", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "parsar-mcode-harness", + "name": "@oac/mcode-harness", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "@pondwader/socks5-server": "1.0.10", diff --git a/packages/mcode-harness/package.json b/packages/mcode-harness/package.json index 0280f4c21..c509c7d02 100644 --- a/packages/mcode-harness/package.json +++ b/packages/mcode-harness/package.json @@ -1,5 +1,5 @@ { - "name": "parsar-mcode-harness", + "name": "@oac/mcode-harness", "private": true, "type": "module", "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 9dd06e4b4..25007798c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ stderr: "pipe", }, { - command: `OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @agents-core-web/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, + 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a53cbe31..c71800d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: apps/web: dependencies: - '@agents-core-web/agents-client': - specifier: workspace:* - version: link:../../packages/agents-client '@base-ui/react': specifier: ^1.8.0 version: 1.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -87,6 +84,9 @@ importers: '@number-flow/react': specifier: ^0.6.2 version: 0.6.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@oac/agents-client': + specifier: workspace:* + version: link:../../packages/agents-client '@tanstack/react-query': specifier: ^5.103.2 version: 5.103.2(react@19.3.0) @@ -157,7 +157,7 @@ importers: example/parsar: dependencies: - '@agents-core-web/agents-client': + '@oac/agents-client': specifier: workspace:* version: link:../../packages/agents-client '@radix-ui/react-dialog': diff --git a/scripts/build-agents-api-image.sh b/scripts/build-agents-api-image.sh deleted file mode 100755 index eb9494662..000000000 --- a/scripts/build-agents-api-image.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -image="${OAC_DEV_CORE_IMAGE:-oac-core:dev}" -if [[ "$runtime_root" != /* ]]; then - printf 'OAC_DEV_HOME must be absolute: %s\n' "$runtime_root" >&2 - exit 1 -fi -mkdir -p "$runtime_root/cache/oac-core-builds" -image_context="$(mktemp -d "$runtime_root/cache/oac-core-builds/image.XXXXXX")" -trap 'rm -rf "$image_context"' EXIT - -# Reuse the source boundary; never send the repository or runtime keys to Docker. -GOOS=linux GOARCH=amd64 OAC_DEV_CORE_BUILD_DIR="$image_context" \ - "$repo_root/scripts/build-agents-api.sh" -E2B_PROVIDER_BUILD_DIR="$image_context/e2b-build" "$repo_root/scripts/build-e2b-provider.sh" -mkdir -p "$image_context/e2b" -tar -xzf "$image_context/e2b-build/oac-e2b-provider-linux-amd64.tar.gz" \ - --strip-components=1 -C "$image_context/e2b" -rm -rf "$image_context/e2b-build" -cp "$repo_root/services/agents-api/Dockerfile" "$image_context/Dockerfile" -docker build --platform linux/amd64 --tag "$image" "$image_context" diff --git a/scripts/build-agents-api-release.sh b/scripts/build-agents-api-release.sh deleted file mode 100755 index 2d79c4bc2..000000000 --- a/scripts/build-agents-api-release.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -output_dir="${OAC_DEV_RELEASE_DIR:-$runtime_root/build/oac-core-release}" -export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" -export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" -python3 - "$HOME/.oac" "$runtime_root" "$output_dir" "$GOCACHE" "$GOMODCACHE" <<'PY' -import pathlib -import sys - -if sys.version_info < (3, 9): - sys.exit("Agents API releases require Python 3.9 or newer") -base = pathlib.Path(sys.argv[1]).resolve() -for value in sys.argv[2:]: - path = pathlib.Path(value) - if not path.is_absolute() or not path.resolve().is_relative_to(base): - sys.exit("Agents API release directories must be absolute and under ~/.oac") -PY - -if [[ -n "${AGENTS_API_RELEASE_RUNTIME_IMAGE:-}" ]]; then - printf 'AGENTS_API_RELEASE_RUNTIME_IMAGE is retired: the Docker-hosted archive could not carry a complete Runtime release. Build the Core distribution (make build-core-distribution) instead\n' >&2 - exit 1 -fi - -require_clean_source() { - local source_status - source_status="$(git -C "$repo_root" status --porcelain --untracked-files=all)" - if [[ -n "$source_status" ]]; then - printf 'Agents API releases require a clean, committed source tree\n' >&2 - exit 1 - fi -} -require_clean_source -source_revision="$(git -C "$repo_root" rev-parse HEAD)" -source_tree="$(git -C "$repo_root" rev-parse "$source_revision^{tree}")" -source_epoch="$(git -C "$repo_root" show -s --format=%ct "$source_revision")" -archive_name="oac-core-$source_revision-linux-amd64.tar.gz" - -mkdir -p "$output_dir" -release_context="$(mktemp -d "$output_dir/.staging.XXXXXX")" -trap 'rm -rf "$release_context"' EXIT -mkdir -p "$release_context/source" "$release_context/package/bin" "$release_context/go-tmp" -export GOTMPDIR="$release_context/go-tmp" -# Build committed bytes so ignored files or concurrent edits cannot change provenance. -git -C "$repo_root" archive "$source_revision" | tar -C "$release_context/source" -xf - -export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOTOOLCHAIN=local -go_version="$(go env GOVERSION)" -required_go="$(awk '$1 == "go" { print "go" $2; exit }' "$release_context/source/go.mod")" -if [[ "$go_version" != "$required_go" ]]; then - printf 'Agents API release requires %s; found %s\n' "$required_go" "$go_version" >&2 - exit 1 -fi -OAC_DEV_BUILD_REVISION="$source_revision" OAC_DEV_CORE_BUILD_DIR="$release_context/package/bin" \ - "$release_context/source/scripts/build-agents-api.sh" -require_clean_source -if [[ "$(git -C "$repo_root" rev-parse HEAD)" != "$source_revision" ]]; then - printf 'Agents API source changed during release build\n' >&2 - exit 1 -fi - -python3 - "$release_context" "$source_revision" "$source_tree" "$source_epoch" "$go_version" "$archive_name" <<'PY' -import gzip -import hashlib -import json -import pathlib -import sys -import tarfile - -root = pathlib.Path(sys.argv[1]) -revision, tree, epoch, go_version, archive_name = sys.argv[2:] -source, package = root / "source", root / "package" -binaries = ["oac-core", "oac-core-migrate", "oac-core-device", "oac-core-environment-key", "oac-node"] - - -def sha256(path): - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -readme = (source / "services/agents-api/RELEASE.md").read_text(encoding="utf-8") -if "@SOURCE_REVISION@" not in readme: - sys.exit("Agents API RELEASE.md must link to @SOURCE_REVISION@") -readme = readme.replace("@SOURCE_REVISION@", revision).replace("@ARCHIVE_NAME@", archive_name.removesuffix(".tar.gz")) -(package / "README.md").write_text(readme, encoding="utf-8") -(package / "LICENSE").write_bytes((source / "LICENSE").read_bytes()) -manifest = { - "artifact": "oac-core", - "source": {"commit": revision, "tree": tree, "commit_timestamp": int(epoch)}, - "platform": {"os": "linux", "architecture": "amd64", "goamd64": "v1"}, - "go_version": go_version, - "upstream_protocol": json.loads((source / "contracts/agents-api/upstream.json").read_text(encoding="utf-8")), - "binaries": {"bin/" + name: {"sha256": sha256(package / "bin" / name)} for name in binaries}, -} -(package / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") -members = sorted(["bin/" + name for name in binaries] + ["LICENSE", "README.md", "manifest.json"]) -(package / "SHA256SUMS").write_text("".join(sha256(package / name) + " " + name + "\n" for name in members), encoding="utf-8") -members = sorted(members + ["SHA256SUMS"]) -prefix = archive_name.removesuffix(".tar.gz") -archive = root / archive_name -with archive.open("wb") as output: - with gzip.GzipFile(filename="", mode="wb", fileobj=output, mtime=0, compresslevel=9) as compressed: - with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as bundle: - for name in members: - path = package / name - info = tarfile.TarInfo(prefix + "/" + name) - info.size = path.stat().st_size - info.mode = 0o755 if name.startswith("bin/") else 0o644 - info.mtime = int(epoch) - with path.open("rb") as contents: - bundle.addfile(info, contents) -(root / (archive_name + ".sha256")).write_text(sha256(archive) + " " + archive_name + "\n", encoding="utf-8") -PY - -mv -f "$release_context/$archive_name" "$release_context/$archive_name.sha256" "$output_dir/" -printf 'Standalone Agents API release: %s/%s\n' "$output_dir" "$archive_name" diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh deleted file mode 100755 index 93cc1fd06..000000000 --- a/scripts/build-agents-api.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -output_dir="${OAC_DEV_CORE_BUILD_DIR:-$runtime_root/build/oac-core}" -for directory in "$runtime_root" "$output_dir"; do - if [[ "$directory" != /* ]]; then - printf 'Agents API build directories must be absolute: %s\n' "$directory" >&2 - exit 1 - fi -done - -revision="${OAC_DEV_BUILD_REVISION:-$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || true)}" -if [[ -n "$revision" && ! "$revision" =~ ^[0-9a-f]{40}$ ]]; then - printf 'Invalid Agents API source revision\n' >&2 - exit 1 -fi - -mkdir -p "$runtime_root/cache/oac-core-builds" -build_context="$(mktemp -d "$runtime_root/cache/oac-core-builds/source.XXXXXX")" -trap 'rm -rf "$build_context"' EXIT - -# This is the release source boundary. Product and other application sources -# must remain physically absent, even when building from the full monorepo. -tar -C "$repo_root" -cf - \ - go.mod go.sum \ - contracts/agents-api/v1 \ - internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/obs/log services/agents-api \ - | tar -C "$build_context" -xf - - -( - cd "$build_context" - export GOWORK=off CGO_ENABLED=0 - for command in server migrate device environment-key sandbox-node; do - artifact="oac-core-$command" - if [[ "$command" == server ]]; then artifact=oac-core; fi - if [[ "$command" == sandbox-node ]]; then artifact=oac-node; fi - go build -mod=readonly -trimpath -buildvcs=false -ldflags "-X main.buildRevision=$revision" \ - -o "$build_context/bin/$artifact" "./services/agents-api/cmd/$command" - done -) - -# Publish only after every command builds successfully. -mkdir -p "$output_dir" -for artifact in oac-core oac-core-migrate oac-core-device oac-core-environment-key oac-node; do - mv -f "$build_context/bin/$artifact" "$output_dir/$artifact" -done -printf 'Standalone Agents API binaries: %s\n' "$output_dir" diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index 464687d31..4b00f5d21 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -28,11 +28,11 @@ trap 'rm -rf "$context"' EXIT ( cd "$repo_root" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon + -o "$context/oac-daemon" ./apps/daemon/cmd/oac-daemon ) cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" -cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/core/deploy/codex/Dockerfile" "$context/Dockerfile" # Preserve the previous bundle if compilation or validation failed. mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 5030dc55e..af5b3b587 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -19,9 +19,9 @@ node "$repo_root/scripts/check-claude-sdk-runtime.mjs" "$context/claude-sdk" ( cd "$repo_root" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon + -o "$context/oac-daemon" ./apps/daemon/cmd/oac-daemon ) -cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/core/deploy/claude/Dockerfile" "$context/Dockerfile" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-claude-sdk-runtime.sh b/scripts/build-claude-sdk-runtime.sh index be62a66d4..982d6a185 100755 --- a/scripts/build-claude-sdk-runtime.sh +++ b/scripts/build-claude-sdk-runtime.sh @@ -19,11 +19,11 @@ cd "$repo_root" # Validate source manifests before deploy derives its dedicated frozen lockfile. test -f pnpm-lock.yaml pnpm install --frozen-lockfile -pnpm --filter @parsar/claude-sdk-adapter build --outDir "$build_context/compiled" +pnpm --filter @oac/claude-sdk-adapter build --outDir "$build_context/compiled" # This package has registry dependencies only. Keep injection local to export; # the ordinary workspace and product installs retain their current settings. pnpm --config.inject-workspace-packages=true --config.extend-node-path=false \ - --filter @parsar/claude-sdk-adapter \ + --filter @oac/claude-sdk-adapter \ deploy --prod "$build_context/runtime" # Discard any incremental checkout output copied by the package exporter. rm -rf "$build_context/runtime/dist" diff --git a/scripts/build-core-console.sh b/scripts/build-core-console.sh deleted file mode 100755 index 0a4ea9852..000000000 --- a/scripts/build-core-console.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -output_dir="${OAC_DEV_WEB_BUILD_DIR:-$runtime_root/build/oac-web}" -export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" -export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" -for directory in "$runtime_root" "$output_dir" "$GOCACHE" "$GOMODCACHE"; do - case "$directory" in - "$HOME/.oac"|"$HOME/.oac/"*) ;; - *) printf 'Core console build directories must be absolute and under ~/.oac\n' >&2; exit 1 ;; - esac - case "/$directory/" in - */../*|*/./*) printf 'Core console build directories must not contain dot segments\n' >&2; exit 1 ;; - esac -done - -mkdir -p "$runtime_root/cache/oac-web-builds" -build_context="$(mktemp -d "$runtime_root/cache/oac-web-builds/source.XXXXXX")" -trap 'rm -rf "$build_context"' EXIT -mkdir -p "$build_context/tmp" -export GOTMPDIR="$build_context/tmp" -# Keep the independent console build separate from API and frontend sources. -tar -C "$repo_root" -cf - go.mod go.sum internal/obs/log services/core-console \ - | tar -C "$build_context" -xf - -( - cd "$build_context" - export GOWORK=off CGO_ENABLED=0 - go build -mod=readonly -trimpath -buildvcs=false \ - -o "$build_context/oac-web" ./services/core-console -) -mkdir -p "$output_dir" -mv -f "$build_context/oac-web" "$output_dir/oac-web" -printf 'Core console binary: %s/oac-web\n' "$output_dir" diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index db7444c3e..eac56fef2 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -109,13 +109,13 @@ python3 scripts/core-distribution-manifest.py bootstraps "$bundle" "$source_epoc # The bundled docs (BUNDLED_DOCS); links that leave them point at this commit on GitHub. python3 scripts/core-distribution-manifest.py docs . "$bundle" "$revision" mkdir -p "$bundle/runtime" -cp services/agents-api/deploy/codex/seccomp.json "$bundle/runtime/" +cp services/core/deploy/codex/seccomp.json "$bundle/runtime/" cp LICENSE "$bundle/" cp -R site "$bundle/site" -OAC_DEV_BUILD_REVISION="$revision" OAC_DEV_CORE_BUILD_DIR="$stage/core/bin" scripts/build-agents-api.sh +OAC_DEV_BUILD_REVISION="$revision" OAC_DEV_CORE_BUILD_DIR="$stage/core/bin" scripts/build-core.sh ( - cd services/agents-api/tools/microsandbox-provider + cd services/core/tools/microsandbox-provider GOWORK=off CGO_ENABLED=1 go build -mod=readonly -trimpath \ -o "$stage/core/bin/oac-microsandbox-provider" . ) @@ -152,16 +152,16 @@ docker run --rm --network none --entrypoint /bin/sh \ "$core_image" -ec \ 'for p in /opt/provider /opt/microsandbox/msb /opt/microsandbox/libkrunfw.so.5.6.1; do ! ldd "$p" | grep "not found"; done; /opt/microsandbox/msb --version' -OAC_DEV_WEB_BUILD_DIR="$stage/web" scripts/build-core-console.sh +OAC_DEV_WEB_BUILD_DIR="$stage/web" scripts/build-web.sh pnpm install --frozen-lockfile OAC_WEB_OPENAI_HOSTED_SESSIONS=1 OAC_WEB_ENVIRONMENT_FILES=1 pnpm build:web cp -R apps/web/dist "$stage/web/dist" -cp services/core-console/Dockerfile "$stage/web/Dockerfile" +cp services/web/Dockerfile "$stage/web/Dockerfile" build_image web "$stage/web" build_image ingress -f deploy/distribution/Ingress.Dockerfile deploy/distribution -CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/oac-daemon" ./apps/daemon/cmd/oac-daemon cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" codex_image="${CORE_DISTRIBUTION_CODEX_IMAGE:-}" claude_image="${CORE_DISTRIBUTION_CLAUDE_IMAGE:-}" diff --git a/scripts/build-core-image.sh b/scripts/build-core-image.sh new file mode 100755 index 000000000..e1135eb03 --- /dev/null +++ b/scripts/build-core-image.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" +image="${OAC_DEV_CORE_IMAGE:-oac-core:dev}" +if [[ "$runtime_root" != /* ]]; then + printf 'OAC_DEV_HOME must be absolute: %s\n' "$runtime_root" >&2 + exit 1 +fi +mkdir -p "$runtime_root/cache/oac-core-builds" +image_context="$(mktemp -d "$runtime_root/cache/oac-core-builds/image.XXXXXX")" +trap 'rm -rf "$image_context"' EXIT + +# Reuse the source boundary; never send the repository or runtime keys to Docker. +GOOS=linux GOARCH=amd64 OAC_DEV_CORE_BUILD_DIR="$image_context" \ + "$repo_root/scripts/build-core.sh" +E2B_PROVIDER_BUILD_DIR="$image_context/e2b-build" "$repo_root/scripts/build-e2b-provider.sh" +mkdir -p "$image_context/e2b" +tar -xzf "$image_context/e2b-build/oac-e2b-provider-linux-amd64.tar.gz" \ + --strip-components=1 -C "$image_context/e2b" +rm -rf "$image_context/e2b-build" +cp "$repo_root/services/core/Dockerfile" "$image_context/Dockerfile" +docker build --platform linux/amd64 --tag "$image" "$image_context" diff --git a/scripts/build-core-release.sh b/scripts/build-core-release.sh new file mode 100755 index 000000000..902919683 --- /dev/null +++ b/scripts/build-core-release.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" +output_dir="${OAC_DEV_RELEASE_DIR:-$runtime_root/build/oac-core-release}" +export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" +export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" +python3 - "$HOME/.oac" "$runtime_root" "$output_dir" "$GOCACHE" "$GOMODCACHE" <<'PY' +import pathlib +import sys + +if sys.version_info < (3, 9): + sys.exit("Agents API releases require Python 3.9 or newer") +base = pathlib.Path(sys.argv[1]).resolve() +for value in sys.argv[2:]: + path = pathlib.Path(value) + if not path.is_absolute() or not path.resolve().is_relative_to(base): + sys.exit("Agents API release directories must be absolute and under ~/.oac") +PY + +if [[ -n "${AGENTS_API_RELEASE_RUNTIME_IMAGE:-}" ]]; then + printf 'AGENTS_API_RELEASE_RUNTIME_IMAGE is retired: the Docker-hosted archive could not carry a complete Runtime release. Build the Core distribution (make build-core-distribution) instead\n' >&2 + exit 1 +fi + +require_clean_source() { + local source_status + source_status="$(git -C "$repo_root" status --porcelain --untracked-files=all)" + if [[ -n "$source_status" ]]; then + printf 'Agents API releases require a clean, committed source tree\n' >&2 + exit 1 + fi +} +require_clean_source +source_revision="$(git -C "$repo_root" rev-parse HEAD)" +source_tree="$(git -C "$repo_root" rev-parse "$source_revision^{tree}")" +source_epoch="$(git -C "$repo_root" show -s --format=%ct "$source_revision")" +archive_name="oac-core-$source_revision-linux-amd64.tar.gz" + +mkdir -p "$output_dir" +release_context="$(mktemp -d "$output_dir/.staging.XXXXXX")" +trap 'rm -rf "$release_context"' EXIT +mkdir -p "$release_context/source" "$release_context/package/bin" "$release_context/go-tmp" +export GOTMPDIR="$release_context/go-tmp" +# Build committed bytes so ignored files or concurrent edits cannot change provenance. +git -C "$repo_root" archive "$source_revision" | tar -C "$release_context/source" -xf - +export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOTOOLCHAIN=local +go_version="$(go env GOVERSION)" +required_go="$(awk '$1 == "go" { print "go" $2; exit }' "$release_context/source/go.mod")" +if [[ "$go_version" != "$required_go" ]]; then + printf 'Agents API release requires %s; found %s\n' "$required_go" "$go_version" >&2 + exit 1 +fi +OAC_DEV_BUILD_REVISION="$source_revision" OAC_DEV_CORE_BUILD_DIR="$release_context/package/bin" \ + "$release_context/source/scripts/build-core.sh" +require_clean_source +if [[ "$(git -C "$repo_root" rev-parse HEAD)" != "$source_revision" ]]; then + printf 'Agents API source changed during release build\n' >&2 + exit 1 +fi + +python3 - "$release_context" "$source_revision" "$source_tree" "$source_epoch" "$go_version" "$archive_name" <<'PY' +import gzip +import hashlib +import json +import pathlib +import sys +import tarfile + +root = pathlib.Path(sys.argv[1]) +revision, tree, epoch, go_version, archive_name = sys.argv[2:] +source, package = root / "source", root / "package" +binaries = ["oac-core", "oac-core-migrate", "oac-core-device", "oac-core-environment-key", "oac-node"] + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +readme = (source / "services/core/RELEASE.md").read_text(encoding="utf-8") +if "@SOURCE_REVISION@" not in readme: + sys.exit("Agents API RELEASE.md must link to @SOURCE_REVISION@") +readme = readme.replace("@SOURCE_REVISION@", revision).replace("@ARCHIVE_NAME@", archive_name.removesuffix(".tar.gz")) +(package / "README.md").write_text(readme, encoding="utf-8") +(package / "LICENSE").write_bytes((source / "LICENSE").read_bytes()) +manifest = { + "artifact": "oac-core", + "source": {"commit": revision, "tree": tree, "commit_timestamp": int(epoch)}, + "platform": {"os": "linux", "architecture": "amd64", "goamd64": "v1"}, + "go_version": go_version, + "upstream_protocol": json.loads((source / "contracts/agents-api/upstream.json").read_text(encoding="utf-8")), + "binaries": {"bin/" + name: {"sha256": sha256(package / "bin" / name)} for name in binaries}, +} +(package / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +members = sorted(["bin/" + name for name in binaries] + ["LICENSE", "README.md", "manifest.json"]) +(package / "SHA256SUMS").write_text("".join(sha256(package / name) + " " + name + "\n" for name in members), encoding="utf-8") +members = sorted(members + ["SHA256SUMS"]) +prefix = archive_name.removesuffix(".tar.gz") +archive = root / archive_name +with archive.open("wb") as output: + with gzip.GzipFile(filename="", mode="wb", fileobj=output, mtime=0, compresslevel=9) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as bundle: + for name in members: + path = package / name + info = tarfile.TarInfo(prefix + "/" + name) + info.size = path.stat().st_size + info.mode = 0o755 if name.startswith("bin/") else 0o644 + info.mtime = int(epoch) + with path.open("rb") as contents: + bundle.addfile(info, contents) +(root / (archive_name + ".sha256")).write_text(sha256(archive) + " " + archive_name + "\n", encoding="utf-8") +PY + +mv -f "$release_context/$archive_name" "$release_context/$archive_name.sha256" "$output_dir/" +printf 'Standalone Agents API release: %s/%s\n' "$output_dir" "$archive_name" diff --git a/scripts/build-core.sh b/scripts/build-core.sh new file mode 100755 index 000000000..4f1663b29 --- /dev/null +++ b/scripts/build-core.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" +output_dir="${OAC_DEV_CORE_BUILD_DIR:-$runtime_root/build/oac-core}" +for directory in "$runtime_root" "$output_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Agents API build directories must be absolute: %s\n' "$directory" >&2 + exit 1 + fi +done + +revision="${OAC_DEV_BUILD_REVISION:-$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || true)}" +if [[ -n "$revision" && ! "$revision" =~ ^[0-9a-f]{40}$ ]]; then + printf 'Invalid Agents API source revision\n' >&2 + exit 1 +fi + +mkdir -p "$runtime_root/cache/oac-core-builds" +build_context="$(mktemp -d "$runtime_root/cache/oac-core-builds/source.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT + +# This is the release source boundary. Product and other application sources +# must remain physically absent, even when building from the full monorepo. +tar -C "$repo_root" -cf - \ + go.mod go.sum \ + contracts/agents-api/v1 \ + internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ + internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/obs/log services/core \ + | tar -C "$build_context" -xf - + +( + cd "$build_context" + export GOWORK=off CGO_ENABLED=0 + for command in server migrate device environment-key sandbox-node; do + artifact="oac-core-$command" + if [[ "$command" == server ]]; then artifact=oac-core; fi + if [[ "$command" == sandbox-node ]]; then artifact=oac-node; fi + go build -mod=readonly -trimpath -buildvcs=false -ldflags "-X main.buildRevision=$revision" \ + -o "$build_context/bin/$artifact" "./services/core/cmd/$command" + done +) + +# Publish only after every command builds successfully. +mkdir -p "$output_dir" +for artifact in oac-core oac-core-migrate oac-core-device oac-core-environment-key oac-node; do + mv -f "$build_context/bin/$artifact" "$output_dir/$artifact" +done +printf 'Standalone Agents API binaries: %s\n' "$output_dir" diff --git a/scripts/build-e2b-provider.sh b/scripts/build-e2b-provider.sh index 89cf24a6d..68660a799 100755 --- a/scripts/build-e2b-provider.sh +++ b/scripts/build-e2b-provider.sh @@ -13,8 +13,8 @@ image="oac-e2b-provider-build:${source_revision:0:12}" # Proxy values are build-only operator settings; no account key is needed. docker build --platform linux/amd64 --build-arg HTTP_PROXY --build-arg HTTPS_PROXY \ --build-arg ALL_PROXY --build-arg NO_PROXY \ - --file "$repo_root/services/agents-api/tools/e2b-provider/Build.Dockerfile" \ - --tag "$image" "$repo_root/services/agents-api/tools/e2b-provider" + --file "$repo_root/services/core/tools/e2b-provider/Build.Dockerfile" \ + --tag "$image" "$repo_root/services/core/tools/e2b-provider" docker run --rm --platform linux/amd64 \ --env HTTP_PROXY --env HTTPS_PROXY --env ALL_PROXY --env NO_PROXY \ --env "E2B_SOURCE_REVISION=$source_revision" \ diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 444dfdc54..04a507698 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -20,9 +20,9 @@ cp -RL "$companion/." "$context/mcode-harness/" ( cd "$repo_root" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon + -o "$context/oac-daemon" ./apps/daemon/cmd/oac-daemon ) -cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/core/deploy/mcode/Dockerfile" "$context/Dockerfile" mkdir -p "$output" cp -R "$context/." "$output/" printf 'MiniMax Code Runtime image context: %s\n' "$output" diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100755 index 000000000..b42de77d6 --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" +output_dir="${OAC_DEV_WEB_BUILD_DIR:-$runtime_root/build/oac-web}" +export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" +export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" +for directory in "$runtime_root" "$output_dir" "$GOCACHE" "$GOMODCACHE"; do + case "$directory" in + "$HOME/.oac"|"$HOME/.oac/"*) ;; + *) printf 'Core console build directories must be absolute and under ~/.oac\n' >&2; exit 1 ;; + esac + case "/$directory/" in + */../*|*/./*) printf 'Core console build directories must not contain dot segments\n' >&2; exit 1 ;; + esac +done + +mkdir -p "$runtime_root/cache/oac-web-builds" +build_context="$(mktemp -d "$runtime_root/cache/oac-web-builds/source.XXXXXX")" +trap 'rm -rf "$build_context"' EXIT +mkdir -p "$build_context/tmp" +export GOTMPDIR="$build_context/tmp" +# Keep the independent console build separate from API and frontend sources. +tar -C "$repo_root" -cf - go.mod go.sum internal/obs/log services/web \ + | tar -C "$build_context" -xf - +( + cd "$build_context" + export GOWORK=off CGO_ENABLED=0 + go build -mod=readonly -trimpath -buildvcs=false \ + -o "$build_context/oac-web" ./services/web +) +mkdir -p "$output_dir" +mv -f "$build_context/oac-web" "$output_dir/oac-web" +printf 'Core console binary: %s/oac-web\n' "$output_dir" diff --git a/scripts/check-names.py b/scripts/check-names.py index b598cf7ae..e7a03e6a4 100644 --- a/scripts/check-names.py +++ b/scripts/check-names.py @@ -16,7 +16,7 @@ r"(?i:parsar)|\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\bAGENTS_API_[A-Z][A-Z0-9_]*|\bCORE_CONSOLE_[A-Z][A-Z0-9_]*" r"|\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider" r"|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\b" - r"|\bcore-console\b|\bagents-runtime-|(?i:\bAgents? Core(?: Web)?\b)", + r"|\bcore-console\b|\bagents-runtime-|(?i:\bAgents? Core(?: Web)?\b)|@agents-core-web/", ) diff --git a/scripts/check-names.test.py b/scripts/check-names.test.py index afb61c719..1410b700e 100644 --- a/scripts/check-names.test.py +++ b/scripts/check-names.test.py @@ -89,26 +89,33 @@ def test_repository_scan_uses_tracked_text_and_skips_binary_without_following_li def test_persisted_domain_exception_does_not_allow_other_settings_or_paths(self): rules = names.load_rules(Path(__file__).with_name("name-allowlist.json")) content = '"parsar.agents-api.credential"; "PARSAR_HOME"' - found = names.violations("services/agents-api/internal/credentialcrypto/cipher.go", content, rules) + found = names.violations("services/core/internal/credentialcrypto/cipher.go", content, rules) self.assertEqual([item[2] for item in found], ["PARSAR"]) self.assertTrue(names.violations("README.md", content, rules)) def test_retirement_table_exception_does_not_hide_runtime_setting(self): rules = names.load_rules(Path(__file__).with_name("name-allowlist.json")) content = '{"AGENTS_API_ADDR", "OAC_ADDR"}; os.Getenv("PARSAR_HOME")' - found = names.violations("services/agents-api/cmd/server/process_configuration.go", content, rules) + found = names.violations("services/core/cmd/server/process_configuration.go", content, rules) self.assertEqual([item[2] for item in found], ["PARSAR"]) def test_checked_in_exceptions_do_not_hide_unrelated_retired_setting(self): rules = names.load_rules(Path(__file__).with_name("name-allowlist.json")) - for content in ('"services/agents-api/cmd/server" PARSAR_HOME', - '"contracts/agents-api/README.md" PARSAR_HOME', - '"github.com/MiniMax-AI/parsar-core" PARSAR_HOME', - '"@parsar/adapter" PARSAR_HOME', + for content in ('"contracts/agents-api/README.md" PARSAR_HOME', '"Parsar product" PARSAR_HOME'): with self.subTest(content=content): self.assertEqual(len(names.violations("README.md", content, rules)), 1) + def test_former_repository_identities_are_rejected(self): + rules = names.load_rules(Path(__file__).with_name("name-allowlist.json")) + for content in ("https://github.com/MiniMax-AI/parsar-core", '"github.com/MiniMax-AI-Dev/parsar/internal/foo"', + "services/agents-api/cmd/server", "services/core-console", "apps/parsar-daemon/cmd/parsar-daemon", + "scripts/build-agents-api-image.sh", "scripts/build-core-console.sh", + "scripts/agents-api-subagents-acceptance.py", "make check-agents-api", '"name": "parsar-core"', + "@agents-core-web/web", "@parsar/claude-sdk-adapter", "parsar-mcode-harness"): + with self.subTest(content=content): + self.assertTrue(names.violations("README.md", content, rules)) + if __name__ == "__main__": unittest.main() diff --git a/scripts/check-sqlc.py b/scripts/check-sqlc.py index c7203f5b1..881a5b2a0 100644 --- a/scripts/check-sqlc.py +++ b/scripts/check-sqlc.py @@ -4,7 +4,7 @@ import subprocess root = Path(__file__).resolve().parent.parent -generated = root / "services/agents-api/internal/db/sqlc" +generated = root / "services/core/internal/db/sqlc" def snapshot(): diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 2bf35c3e4..2b7a98226 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -58,7 +58,7 @@ "docs/assets/console-agent-metrics-en.webp", "docs/assets/console-agent-metrics-zh.webp", ) -REPOSITORY_URL = "https://github.com/MiniMax-AI/parsar-core" +REPOSITORY_URL = "https://github.com/MiniMax-AI/OpenAgentCore" MARKDOWN_LINK = re.compile(r"(!?)\[((?:[^\[\]]|\[[^\]]*\])*)\]\(([^)\s]+)((?:\s+\"[^\"]*\")?)\)") FENCE = re.compile(r" {0,3}(`{3,}|~{3,})") HEADING = re.compile(r" {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*\Z") diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 159bd0e2d..aff1cb625 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -293,7 +293,7 @@ def setUp(self): "[Install](docs/install.md#sign-in-to-web), [API](contracts/api.md#routes), [web](docs/web), " "`[kept](missing.md)`, [`schema.json`](contracts/schema.json), " "[![Chart](docs/chart.png)](contracts/api.md), " - "[main](https://github.com/MiniMax-AI/parsar-core/blob/main/LICENSE)\n" + "[main](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/LICENSE)\n" "```sh\n[not a link](missing.md)\n```\n\n [indented code](missing.md)\n", "docs/install.md": "# Install\n## Sign in to Web\n## C#\n## _Emphasis_ and snake_case\n" "[Back](../README.md#title) [Here](#sign-in-to-web) [C](#c) [E](#emphasis-and-snake_case)\n" @@ -312,7 +312,7 @@ def bundle_docs(self): def test_links_leaving_the_bundle_point_at_the_revision(self): self.bundle_docs() - versioned = "https://github.com/MiniMax-AI/parsar-core/{}/" + REVISION + "/" + versioned = "https://github.com/MiniMax-AI/OpenAgentCore/{}/" + REVISION + "/" self.assertEqual((self.bundle / "README.md").read_text(), ( "# Title\n\n![Banner](docs/banner.png) ![Chart](" + versioned.format("raw") + "docs/chart.png)\n" "[Install](docs/install.md#sign-in-to-web), [API](" + versioned.format("blob") + "contracts/api.md#routes), " diff --git a/scripts/core-doctor.mjs b/scripts/core-doctor.mjs index a93772ab2..464889643 100644 --- a/scripts/core-doctor.mjs +++ b/scripts/core-doctor.mjs @@ -765,7 +765,7 @@ async function validateParsarCheckout(parsarPath) { const [rootMetadata, moduleMetadata, commandMetadata] = await Promise.all([ stat(root), stat(join(root, "go.mod")), - stat(join(root, "apps/parsar-daemon/cmd/parsar-daemon/main.go")), + stat(join(root, "apps/daemon/cmd/oac-daemon/main.go")), ]); if (!rootMetadata.isDirectory() || !moduleMetadata.isFile() || !commandMetadata.isFile()) return undefined; return root; @@ -786,7 +786,7 @@ export async function inspectDaemon({ parsarPath, profile, timeoutMs, env, runCo return { exitCode: CORE_DOCTOR_EXIT_CODES.usageOrInternalError }; } command = "go"; - args = ["run", "./apps/parsar-daemon/cmd/parsar-daemon", "status", "--profile", profile]; + args = ["run", "./apps/daemon/cmd/oac-daemon", "status", "--profile", profile]; } else { command = "oac-daemon"; args = ["status", "--profile", profile]; diff --git a/scripts/core-doctor.test.mjs b/scripts/core-doctor.test.mjs index 8ffa6a676..4bfb0f62f 100644 --- a/scripts/core-doctor.test.mjs +++ b/scripts/core-doctor.test.mjs @@ -706,9 +706,9 @@ test("fails closed when a network target tries to expand an unrelated secret", a test("uses an explicit Parsar checkout only for an allowlisted daemon status command", async (t) => { const state = await createLocalState(t); const parsarPath = join(state.root, "private-parsar-checkout"); - await mkdir(join(parsarPath, "apps", "parsar-daemon", "cmd", "parsar-daemon"), { recursive: true }); + await mkdir(join(parsarPath, "apps", "daemon", "cmd", "oac-daemon"), { recursive: true }); await writeFile(join(parsarPath, "go.mod"), "module fixture.invalid/parsar\n"); - await writeFile(join(parsarPath, "apps", "parsar-daemon", "cmd", "parsar-daemon", "main.go"), "package main\n"); + await writeFile(join(parsarPath, "apps", "daemon", "cmd", "oac-daemon", "main.go"), "package main\n"); const { fetchImpl } = await successfulFetchRecorder(); let commandCall; const result = await runScenario({ @@ -733,7 +733,7 @@ test("uses an explicit Parsar checkout only for an allowlisted daemon status com assert.equal(commandCall.cwd, parsarPath); assert.deepEqual(commandCall.args, [ "run", - "./apps/parsar-daemon/cmd/parsar-daemon", + "./apps/daemon/cmd/oac-daemon", "status", "--profile", "fixture-profile", diff --git a/scripts/agents-api-subagents-acceptance.py b/scripts/core-subagents-acceptance.py similarity index 100% rename from scripts/agents-api-subagents-acceptance.py rename to scripts/core-subagents-acceptance.py diff --git a/scripts/generate-harness-catalog.py b/scripts/generate-harness-catalog.py index 335a65346..c9b8d6eb8 100644 --- a/scripts/generate-harness-catalog.py +++ b/scripts/generate-harness-catalog.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parent.parent CATALOG = Path("internal/harnessconfig/builtin/catalog.json") -MODULE = "github.com/MiniMax-AI-Dev/parsar" +MODULE = "github.com/MiniMax-AI/OpenAgentCore" HEADER = "// Code generated by scripts/generate-harness-catalog.py; DO NOT EDIT.\n" @@ -83,7 +83,7 @@ def render(entries): return ok }} '''), - Path("services/agents-api/internal/engine/catalog_generated.go"): go(f'''package engine + Path("services/core/internal/engine/catalog_generated.go"): go(f'''package engine var qualified = NewCatalog(map[string]Profile{{ {profiles} @@ -109,7 +109,7 @@ def render(entries): {rows} Configuration declarations live in {tick}internal/harnessconfig/{tick}; -qualification constructors live in {tick}services/agents-api/internal/engine{tick}. +qualification constructors live in {tick}services/core/internal/engine{tick}. These registrations describe the build. Deployment enablement, qualified operations and a connected Runtime's actual availability remain separate checks. See [Harness onboarding](harness-onboarding.md) for adapter and packaging steps. diff --git a/scripts/generate-harness-catalog.test.py b/scripts/generate-harness-catalog.test.py index cc921ca05..67af0f0d0 100644 --- a/scripts/generate-harness-catalog.test.py +++ b/scripts/generate-harness-catalog.test.py @@ -33,7 +33,7 @@ def test_new_registration_reaches_all_projections(self): for old in ("codex", "claude_sdk", "mcode"): self.assertNotIn(old, content) self.assertIn('"example": example.Configuration()', generated[Path("internal/harnessconfig/builtin/registry.go")]) - self.assertIn('"example": exampleProfile()', generated[Path("services/agents-api/internal/engine/catalog_generated.go")]) + self.assertIn('"example": exampleProfile()', generated[Path("services/core/internal/engine/catalog_generated.go")]) def test_checked_in_openapi_enums_match_catalog(self): expected = [entry["kind"] for entry in catalog.load_catalog(catalog.ROOT / catalog.CATALOG)] diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 4062e6067..05b6f6f67 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -1,29 +1,9 @@ [ - { - "path": "*", - "regex": "github\\.com/(?:MiniMax-AI(?:-Dev)?)/parsar(?:-core)?(?:/[A-Za-z0-9_./-]*)?", - "reason": "The repository and Go module paths are deferred public identities; their spelling does not select a runtime setting." - }, - { - "path": "*", - "regex": "(?:services/agents-api|services/core-console|apps/parsar-daemon)(?:/[A-Za-z0-9_.*{}-]+)*", - "reason": "The three source directory names and their command directories remain unchanged." - }, { "path": "*", "regex": "contracts/agents-api(?:/[A-Za-z0-9_.*-]+)*", "reason": "The Agents API contract directory is an existing source path, not an installed executable." }, - { - "path": "*", - "regex": "(?:scripts/)?build-agents-api(?:-[A-Za-z0-9-]+)?\\.sh", - "reason": "Build script filenames are explicitly deferred." - }, - { - "path": "*", - "regex": "@(?:parsar|agents-core-web)/[a-z0-9-]+", - "reason": "Existing npm package names are deferred; imports must continue resolving." - }, { "path": "*", "regex": "~?/\\.parsar/remediation/[A-Za-z0-9_./-]+", @@ -61,12 +41,12 @@ }, { "path": "scripts/name-allowlist.json", - "regex": "(?i)parsar|\\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\\bAGENTS_API_[A-Z][A-Z0-9_]*|\\bCORE_CONSOLE_[A-Z][A-Z0-9_]*|\\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\\b|\\bcore-console\\b|\\bagents-runtime-|\\bAgents? Core(?: Web)?\\b", + "regex": "(?i)parsar|\\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\\bAGENTS_API_[A-Z][A-Z0-9_]*|\\bCORE_CONSOLE_[A-Z][A-Z0-9_]*|\\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\\b|\\bcore-console\\b|\\bagents-runtime-|\\bAgents? Core(?: Web)?\\b|@agents-core-web/", "reason": "The reviewed guard policy must spell the names it explains; entries are checked for nonempty reasons." }, { "path": "scripts/check-names.test.py", - "regex": "(?i)parsar|\\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\\bAGENTS_API_[A-Z][A-Z0-9_]*|\\bCORE_CONSOLE_[A-Z][A-Z0-9_]*|\\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\\b|\\bcore-console\\b|\\bagents-runtime-|\\bAgents? Core(?: Web)?\\b", + "regex": "(?i)parsar|\\bAGENTS_CORE_WEB_[A-Z][A-Z0-9_]*|\\bAGENTS_API_[A-Z][A-Z0-9_]*|\\bCORE_CONSOLE_[A-Z][A-Z0-9_]*|\\bagents-api(?:-(?:migrate|device|environment-key|e2b-provider|microsandbox-provider|tool-root|codex-directory|codex-write|workspace-export|runtime-initialize|claude-shell-prefix))?\\b|\\bcore-console\\b|\\bagents-runtime-|\\bAgents? Core(?: Web)?\\b|@agents-core-web/", "reason": "Guard regression fixtures intentionally contain retired identifiers, including rejected examples." }, { @@ -74,55 +54,40 @@ "regex": "X-Core-Console-Actor", "reason": "This existing HTTP header is explicitly retained; it is a wire contract, not the Web binary name." }, - { - "path": "README.md", - "regex": "MiniMax-AI/parsar-core", - "reason": "The GitHub CLI repository argument retains the repository identity." - }, - { - "path": "README.md", - "regex": "make build-agents-api", - "reason": "This existing Make target calls the deferred build script name." - }, { "path": "*", "regex": "\\bPARSAR_(?:CAPABILITY_UPLOAD_TOKEN|SERVER_URL|MASTER_KEY|PROTOCOL_BASELINE_REVISION)\\b", "reason": "These four settings belong to the separate product integration; Core does not rename their protocol." }, { - "path": "apps/parsar-daemon/internal/cli/retired_configuration.go", + "path": "apps/daemon/internal/cli/retired_configuration.go", "regex": "PARSAR_[A-Z0-9_]*\\*?", "reason": "This file only rejects retired Runtime settings, maps their replacements and exempts the separate product hooks." }, { - "path": "apps/parsar-daemon/internal/cli/retired_configuration_test.go", + "path": "apps/daemon/internal/cli/retired_configuration_test.go", "regex": "PARSAR_[A-Z0-9_]*", "reason": "These fixtures assert rejection and replacement messages for retired Runtime settings." }, { - "path": "services/agents-api/cmd/server/process_configuration.go", + "path": "services/core/cmd/server/process_configuration.go", "regex": "AGENTS_API_[A-Z0-9_]+", "reason": "The Core startup retirement table must identify every rejected old setting and its replacement." }, { - "path": "services/agents-api/cmd/server/process_configuration_test.go", + "path": "services/core/cmd/server/process_configuration_test.go", "regex": "AGENTS_API_[A-Z0-9_]+", "reason": "These fixtures assert Core startup rejection of retired settings." }, { - "path": "services/agents-api/migrations/000078_oac_runtime_names.sql", + "path": "services/core/migrations/000078_oac_runtime_names.sql", "regex": "parsar-core-runtime@", "reason": "The guarded up/down data migration must recognize and restore the historical stored Runtime reference prefix." }, { "path": "*", - "regex": "(?_-]+)?|Parsar Anthropic", "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." }, { - "path": "apps/parsar-daemon/internal/agent/pi/provider_config_test.go", + "path": "apps/daemon/internal/agent/pi/provider_config_test.go", "regex": "parsar(?:/[A-Za-z0-9<>_-]+)?|Parsar Anthropic", "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." }, { - "path": "apps/parsar-daemon/internal/agent/pi/session_provider_test.go", + "path": "apps/daemon/internal/agent/pi/session_provider_test.go", "regex": "parsar(?:/[A-Za-z0-9<>_-]+)?|Parsar Anthropic", "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." }, { - "path": "apps/parsar-daemon/internal/cli/skill_upload.go", + "path": "apps/daemon/internal/cli/skill_upload.go", "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." }, { - "path": "apps/parsar-daemon/internal/cli/skill_upload_test.go", + "path": "apps/daemon/internal/cli/skill_upload_test.go", "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." }, { - "path": "apps/parsar-daemon/internal/cli/companion_path_test.go", + "path": "apps/daemon/internal/cli/companion_path_test.go", "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." }, { - "path": "apps/parsar-daemon/internal/cli/capability_downloads_test.go", + "path": "apps/daemon/internal/cli/capability_downloads_test.go", "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", "reason": "The dormant product capability upload/download integration locates the separate product CLI and checks its product credentials; it is explicitly retained." }, @@ -426,13 +371,13 @@ }, { "path": "scripts/core-doctor.mjs", - "regex": "parsarPath|validateParsarCheckout|--parsar|private-parsar-checkout|fixture\\.invalid/parsar|\"parsar-daemon\"|Parsar (?:protocol baseline|protocol compatibility|Agents API contract)", - "reason": "The optional doctor checkout argument and baseline compare the separate product source/protocol; the daemon command itself uses oac-daemon." + "regex": "parsarPath|validateParsarCheckout|--parsar|private-parsar-checkout|fixture\\.invalid/parsar|Parsar (?:protocol baseline|protocol compatibility|Agents API contract)", + "reason": "The optional --parsar argument names an OpenAgentCore checkout whose daemon status command the doctor runs; the installed daemon is oac-daemon. The protocol baseline names the pinned Parsar protocol revision." }, { "path": "scripts/core-doctor.test.mjs", - "regex": "parsarPath|validateParsarCheckout|--parsar|private-parsar-checkout|fixture\\.invalid/parsar|\"parsar-daemon\"|Parsar (?:protocol baseline|protocol compatibility|Agents API contract)", - "reason": "The optional doctor checkout argument and baseline compare the separate product source/protocol; the daemon command itself uses oac-daemon." + "regex": "parsarPath|validateParsarCheckout|--parsar|private-parsar-checkout|fixture\\.invalid/parsar|Parsar (?:protocol baseline|protocol compatibility|Agents API contract)", + "reason": "The optional --parsar argument names an OpenAgentCore checkout whose daemon status command the doctor runs; the installed daemon is oac-daemon. The protocol baseline names the pinned Parsar protocol revision." }, { "path": "scripts/core-doctor.mjs", @@ -540,17 +485,17 @@ "reason": "These exact phrases refer to the separate Parsar product, its ownership or historical source, not the OpenAgentCore brand." }, { - "path": "services/agents-api/README.md", + "path": "services/core/README.md", "regex": "Parsar's product|Parsar\\sworkspace|Parsar Skill/SP", "reason": "These exact phrases refer to the separate Parsar product, its ownership or historical source, not the OpenAgentCore brand." }, { - "path": "services/agents-api/credentials.md", + "path": "services/core/credentials.md", "regex": "Parsar approval", "reason": "These exact phrases refer to the separate Parsar product, its ownership or historical source, not the OpenAgentCore brand." }, { - "path": "services/agents-api/deploy/claude/README.md", + "path": "services/core/deploy/claude/README.md", "regex": "Parsar is not a dependency", "reason": "These exact phrases refer to the separate Parsar product, its ownership or historical source, not the OpenAgentCore brand." }, @@ -580,17 +525,17 @@ "reason": "These names appear in retirement documentation or instructions for an explicitly pinned historical release; current configuration uses OAC settings." }, { - "path": "services/agents-api/README.md", + "path": "services/core/README.md", "regex": "(?:AGENTS_API_|CORE_CONSOLE_|PARSAR_)[A-Z0-9_]*\\*?|X-Parsar-Node-ID", "reason": "These names appear in retirement documentation or instructions for an explicitly pinned historical release; current configuration uses OAC settings." }, { - "path": "services/agents-api/deploy/codex/README.md", + "path": "services/core/deploy/codex/README.md", "regex": "(?:AGENTS_API_|CORE_CONSOLE_|PARSAR_)[A-Z0-9_]*\\*?|X-Parsar-Node-ID", "reason": "These names appear in retirement documentation or instructions for an explicitly pinned historical release; current configuration uses OAC settings." }, { - "path": "services/agents-api/deploy/microsandbox/README.md", + "path": "services/core/deploy/microsandbox/README.md", "regex": "(?:AGENTS_API_|CORE_CONSOLE_|PARSAR_)[A-Z0-9_]*\\*?|X-Parsar-Node-ID", "reason": "These names appear in retirement documentation or instructions for an explicitly pinned historical release; current configuration uses OAC settings." }, @@ -610,7 +555,7 @@ "reason": "The source-copy boundary rejects the separate product application directory." }, { - "path": "services/agents-api/tests/official_environment_plugin_mcp.py", + "path": "services/core/tests/official_environment_plugin_mcp.py", "regex": "'PARSAR_'", "reason": "The external-client credential-leak test rejects product-secret prefixes in observed model tool output; this is a leak detector, not a runtime setting." }, @@ -690,43 +635,63 @@ "reason": "The explicitly named Parsar application example retains product branding; it does not rename the Core runtime." }, { - "path": "apps/parsar-daemon/internal/cli/connect_cleanup_test.go", + "path": "apps/daemon/internal/cli/connect_cleanup_test.go", "regex": "\"agents-api-cleanup\"", "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." }, { - "path": "apps/parsar-daemon/internal/dispatch/executor.go", + "path": "apps/daemon/internal/dispatch/executor.go", "regex": "\"agents-api-(?:session)?\"", "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." }, { - "path": "apps/parsar-daemon/internal/dispatch/executor_handoff_test.go", + "path": "apps/daemon/internal/dispatch/executor_handoff_test.go", "regex": "\"agents-api-(?:session)?\"", "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." }, { - "path": "apps/parsar-daemon/internal/dispatch/executor_test.go", + "path": "apps/daemon/internal/dispatch/executor_test.go", "regex": "\"agents-api-(?:session)?\"", "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." }, { - "path": "apps/parsar-daemon/internal/localworkspace/capabilities.go", + "path": "apps/daemon/internal/localworkspace/capabilities.go", "regex": "\"agents-api-\"", "reason": "AgentStateKey retains the existing daemon/native-session resume identity; capability snapshots bind to the Session UUID carried in that identity." }, { - "path": "apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go", + "path": "apps/daemon/internal/localworkspace/capability_preparation_test.go", "regex": "\"agents-api-\"", "reason": "AgentStateKey retains the existing daemon/native-session resume identity; capability snapshots bind to the Session UUID carried in that identity." }, { - "path": "apps/parsar-daemon/internal/contracttest/wire_test.go", + "path": "apps/daemon/internal/contracttest/wire_test.go", "regex": "agents-api-session", "reason": "The controlled Runtime fixture uses the existing Core Session state-key prefix required by execution admission." }, { - "path": "apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go", + "path": "apps/daemon/internal/agent/mcode/tool_environment_test.go", "regex": "agents-api-", "reason": "The regression fixture uses the existing persisted Session state-key namespace required by Runtime binding validation." + }, + { + "path": "contracts/agents-api/README.md", + "regex": "https://github\\.com/MiniMax-AI-Dev/parsar/pull/[0-9]+", + "reason": "Historical acceptance evidence links to pull requests in the separate upstream Parsar repository." + }, + { + "path": "scripts/verify-source-copy.py", + "regex": "(?:services/agents-api|apps/parsar-daemon)/[A-Za-z0-9_./-]*", + "reason": "Source-copy records keep their upstream paths; the verifier maps them to the current directories." + }, + { + "path": "services/core/migrations/000015_retire_item_backfill.sql", + "regex": "services/agents-api/README\\.md", + "reason": "Landed migrations keep their original text." + }, + { + "path": "packages/agents-client/src/fixtures/parsar-*", + "regex": "services/agents-api/internal/api/", + "reason": "Historical Parsar protocol snapshots record the upstream source path they were captured from." } ] diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index 7150b2b44..3837b8fed 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -30,7 +30,7 @@ const server = createServer(async (request, response) => { const json = (status, value) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); }; if (url.pathname.endsWith('.sha256')) { response.setHeader('Content-Type', 'text/plain; charset=utf-8'); return response.end(checksum+'\n'); } if (url.pathname.endsWith('.tar.gz')) return createReadStream(archive).pipe(response); - if (url.pathname.endsWith('bootstrap.sh') || url.pathname.endsWith('bootstrap.ps1')) return createReadStream(resolve('services/agents-api/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); + if (url.pathname.endsWith('bootstrap.sh') || url.pathname.endsWith('bootstrap.ps1')) return createReadStream(resolve('services/core/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); const body = []; for await (const chunk of request) body.push(chunk); if (url.pathname.endsWith('/installation') || url.pathname.endsWith('/claim')) { if (request.headers.authorization !== `Bearer ${authorization}`) return json(401, {}); @@ -80,7 +80,7 @@ try { assert.notEqual((await run(executable, [...args, '--non-interactive', '--harness', 'codex'])).code, 0, 'Lost claim response should fail safely'); const saved = JSON.parse(await readFile(join(installation, 'daemon', 'executor-credential.json'), 'utf8')); assert.equal(saved.executor_token, secret, 'Secret must survive a lost response'); - const script = resolve(`services/agents-api/internal/nativeinstaller/assets/bootstrap.${windows ? 'ps1' : 'sh'}`); + const script = resolve(`services/core/internal/nativeinstaller/assets/bootstrap.${windows ? 'ps1' : 'sh'}`); const bootstrapArgs = windows ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Base', base, '-Authorization', authorization] : [script, base, authorization]; const result = await run(windows ? 'powershell.exe' : 'bash', [...bootstrapArgs, '--install-dir', installation], '\n\n'); assert.equal(result.code, 0, `Interactive bootstrap failed: ${result.output}`); diff --git a/scripts/promote-qualified-release.py b/scripts/promote-qualified-release.py index c4dbd137f..bae72ec3c 100644 --- a/scripts/promote-qualified-release.py +++ b/scripts/promote-qualified-release.py @@ -6,7 +6,7 @@ files, and it creates the unpublished build- draft itself. Produce the files with make build-core-distribution for that commit, with CORE_DISTRIBUTION_RELEASE_BASE_URL set to -https://github.com/MiniMax-AI/parsar-core/releases/download/build-, +https://github.com/MiniMax-AI/OpenAgentCore/releases/download/build-, CORE_DISTRIBUTION_OFFLINE=1 and the native installer catalog, or take the Actions artifact of a manual core-release run with draft_release=false and remove install.sh and install.sh.sha256. A draft that core-release created holds @@ -96,7 +96,7 @@ distribution = importlib.util.module_from_spec(spec) spec.loader.exec_module(distribution) -REPO = "MiniMax-AI/parsar-core" +REPO = "MiniMax-AI/OpenAgentCore" SOURCE = TAG = BASE = STEM = None diff --git a/scripts/promote-qualified-release.test.py b/scripts/promote-qualified-release.test.py index 0637a5247..cc85a345d 100644 --- a/scripts/promote-qualified-release.test.py +++ b/scripts/promote-qualified-release.test.py @@ -127,7 +127,7 @@ def test_missing_manifest_artifact_rejected(self): def test_unlanded_and_unrelated_main_changes_rejected(self): for comparison in ({"status": "diverged", "files": []}, - {"status": "ahead", "files": [{"filename": "services/agents-api/main.go"}]}, + {"status": "ahead", "files": [{"filename": "services/core/main.go"}]}, {"status": "ahead", "files": [{"filename": "Makefile", "previous_filename": "go.mod"}]}): with self.subTest(comparison=comparison), mock.patch.object( promotion, "api", side_effect=[{"commit": {"tree": {"sha": self.tree}}}, comparison]): diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index 2d678eb5d..7da741a5b 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -46,7 +46,7 @@ def setUp(self): path.with_name(path.name + ".sha256").write_text(publisher.distribution.sha256(path) + " " + path.name + "\n") self.release = None self.existing = [] - self.canonical_repository = "MiniMax-AI/parsar-core" + self.context_repository = self.canonical_repository = "MiniMax-AI/OpenAgentCore" stack = contextlib.ExitStack() self.addCleanup(stack.close) self.api = stack.enter_context(mock.patch.object(publisher, "api", side_effect=self.response)) @@ -64,7 +64,7 @@ def test_corrupt_native_asset_refuses_release_creation(self): self.assertEqual(self.writes(), []) def response(self, repository, endpoint, *args): - if endpoint == "https://api.github.com/repos/MiniMax-AI/parsar-core": + if endpoint == "https://api.github.com/repos/" + self.context_repository: return {"full_name": self.canonical_repository} if endpoint.startswith("git/"): return {"object": {"type": "commit", "sha": self.revision}} @@ -97,7 +97,7 @@ def response(self, repository, endpoint, *args): raise subprocess.CalledProcessError(1, ["gh", "api", endpoint]) def publish(self, tag="v1.2.3", mode="publish"): - publisher.publish(self.assets, "MiniMax-AI/parsar-core", self.revision, tag, mode) + publisher.publish(self.assets, self.context_repository, self.revision, tag, mode) def writes(self): return [c for c in self.api.call_args_list if "--method" in c.args] @@ -111,11 +111,11 @@ def test_version_tag_publishes_complete_fixed_id(self): ("releases/7", "--method", "PATCH", "-F", "draft=false")) def test_repository_rename_uses_current_identity_before_writes(self): - self.canonical_repository = "MiniMax-AI/OpenAgentCore" + self.context_repository = "MiniMax-AI/previous-name" self.publish() calls = self.api.call_args_list - self.assertEqual(calls[0].args, ("MiniMax-AI/parsar-core", - "https://api.github.com/repos/MiniMax-AI/parsar-core")) + self.assertEqual(calls[0].args, ("MiniMax-AI/previous-name", + "https://api.github.com/repos/MiniMax-AI/previous-name")) self.assertTrue(all(c.args[0] == self.canonical_repository for c in calls[1:])) uploads = [c for c in calls if c.args[1].startswith("https://uploads.")] self.assertEqual(len(uploads), len(self.release["assets"])) @@ -280,8 +280,8 @@ def response(repo, endpoint, *args): def test_api_uses_full_upload_url_and_binary_input(self): with mock.patch.object(publisher.subprocess, "check_output", return_value='{"id": 7}') as command: - url = "https://uploads.github.com/repos/MiniMax-AI/parsar-core/releases/7/assets?name=x" - self.assertEqual(REAL_API("MiniMax-AI/parsar-core", url, "--method", "POST", + url = "https://uploads.github.com/repos/MiniMax-AI/OpenAgentCore/releases/7/assets?name=x" + self.assertEqual(REAL_API("MiniMax-AI/OpenAgentCore", url, "--method", "POST", "--input", "/tmp/asset"), {"id": 7}) self.assertEqual(command.call_args.args[0], ["gh", "api", url, "--method", "POST", "--input", "/tmp/asset"]) diff --git a/scripts/verify-source-copy.py b/scripts/verify-source-copy.py index 96ba54e11..975d520ac 100644 --- a/scripts/verify-source-copy.py +++ b/scripts/verify-source-copy.py @@ -16,10 +16,25 @@ "services/agents-api/RELEASE.md": "Resolve new release revisions in parsar-core.", "services/agents-api/HOSTED-RELEASE.md": "Resolve new release revisions in parsar-core.", } +# Records keep their upstream paths; these copied roots now live under new names. +moved = { + "apps/parsar-daemon/cmd/parsar-daemon/": "apps/daemon/cmd/oac-daemon/", + "apps/parsar-daemon/": "apps/daemon/", + "services/agents-api/": "services/core/", +} + + +def destination(name): + for source, target in moved.items(): + if name.startswith(source): + return target + name[len(source):] + return name + + errors = [] unchanged = 0 for name, expected in manifest["files"].items(): - path = root / name + path = root / destination(name) if not path.is_file(): errors.append("missing: " + name) elif hashlib.sha256(path.read_bytes()).hexdigest() == expected: diff --git a/services/agents-api/README.md b/services/agents-api/README.md deleted file mode 100644 index c46b4a3cb..000000000 --- a/services/agents-api/README.md +++ /dev/null @@ -1,685 +0,0 @@ -# Agents API - -See [Configuration](../../docs/configuration.md) for process, deployment, node and daemon configuration ownership. - -Independent execution service implementing part of the pinned OpenAI Agents API. -It owns reusable Agents, durable Sessions/Turns/Items, live events, function actions -and a daemon execution worker. Public execution supports qualified Codex, Claude Code -(`claude_sdk`) and MiniMax Code (`mcode`) profiles through the shared Runtime contract. -The three-harness Linux amd64 Docker V1 MVP has accepted evidence. V1 user-managed -Runtime enrollment has [recorded real acceptance](../../contracts/agents-api/user-managed-runtime-v1.md) -with explicit deployment coverage. [E2B](deploy/e2b/README.md) can back Core-managed -hosted sandboxes, selected in Web's sandbox setup, or application-managed -`self_hosted` Environments. -It builds and runs with its own PostgreSQL database and credentials; -Parsar's product service, frontend and database are not required. - -Use this guide to build, configure and connect a client. The -[protocol coverage](../../contracts/agents-api/README.md) lists supported operations, -engine limits, acceptance evidence and missing resources. The target remains the -complete pinned protocol; current workflows do not establish full compatibility. -Parsar product execution and its eventual public-client cutover are separate. - -## Reusable Agents - -Static-bearer and OAuth Vault Credentials support creation, replacement, deletion -and safe metadata retrieval/listing. Vault deletion atomically removes its -Credentials. Configure their independent encryption key and authenticated Session -use through the [credential guide](credentials.md); see [OAuth credentials](oauth-credentials.md) -for application authorization, dispatch-time refresh and revocation boundaries. - -The pinned Python client saves an Agent independently, then starts a Session with initial input: - -```python -agent = client.beta.agents.create(model="your-model", name="Example") -session = client.beta.agents.sessions.create( - agent_id=agent.id, environment={"type": "none"}, input="Hello", -) -``` - -These resources belong to the authenticated execution tenant. Saving configuration -does not launch an engine. Optional Session `agent` fields override the saved -configuration: omitted fields inherit, supplied objects/arrays replace whole fields. -Source and Session metadata stay separate; execution never looks up the source again. - -- Retrieve with `client.beta.agents.retrieve(agent.id)`; list saved resources with - `client.beta.agents.list(limit=20, order="desc")` and SDK auto-pagination. -- Update with `client.beta.agents.update(agent.id, instructions="New instructions")`. - Omitted fields remain unchanged. Metadata replaces all pairs; null/empty clears it. - Existing Sessions retain their configuration; new Sessions resolve the update. -- Delete with `client.beta.agents.delete(agent.id)`. Existing Sessions and history - remain available. New references fail; recorded creation retries recover their - accepted snapshot without consulting the deleted source. -- List Sessions with `client.beta.agents.sessions.list(agent_id=agent.id)`. Filtering - uses the immutable root ID, including inline Agents and history after source changes. - -See the [configuration and retry limits](../../contracts/agents-api/README.md#public-semantics) -before relying on optional settings or hosted error/default equivalence. - -## Build standalone binaries - -```bash -make build-agents-api -# Optional absolute output directory: -OAC_DEV_CORE_BUILD_DIR="$HOME/.oac/build/oac-core-test" make build-agents-api -``` - -The default output is `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core`: - -- `oac-core`: HTTP service and execution worker. -- `oac-core-migrate`: this service's embedded database migrations. -- `oac-core-device`: operator device provisioning and revocation. -- `oac-core-environment-key`: principal executor key issuance, rotation and revocation. - -Use these executables in place of the corresponding `go run` commands below. -The build needs Go and access to its pinned module dependencies; it does not need -Node, Docker, the product service or frontend. An isolated source context enforces -that boundary on every build. [Contributor rules](../../docs/maintainers.md#standalone-core-builds) -define the allowed shared packages and required checks. Runtime database/key -configuration and a separately installed execution daemon are still required; -these binaries do not establish full protocol coverage. For a standalone Linux -container, see [Run Core without the installer](../../docs/maintainers.md#run-core-without-the-installer). - -`make build-agents-api-release` packages these commands and `oac-node` in a versioned -Linux amd64 archive, with source/protocol identity, checksums, a license and -[operator instructions](RELEASE.md). Build from a clean Git worktree with Go and -Python 3.9+; output defaults to `~/.oac/build/oac-core-release` (or -`OAC_DEV_RELEASE_DIR`). The extracted API needs no source checkout or compiler. -The archive and the container are advanced paths for running Core alone; see -[Maintainers and advanced deployments](../../docs/maintainers.md). The Docker-hosted -archive variant (`AGENTS_API_RELEASE_RUNTIME_IMAGE`) is retired: it recorded only an -image ID, not the complete Runtime release a Docker deployment requires. Docker-hosted -deployments use the Core distribution and its -[installer](../../docs/getting-started/install.md), whose manifest carries the complete -release; Docker nodes are added from Web. - -## Database ownership - -Use a dedicated PostgreSQL database and account, separate from the Parsar product. -This service does not import `server/internal` or apply product migrations. -Migrations are embedded and tracked in `agents_api_schema_version`. - -```bash -OAC_DATABASE_URL='postgres://.../agents_api' \ - go run ./services/agents-api/cmd/migrate -``` - -The public `Idempotency-Key` creation header is optional: omission creates a new -Session. A supplied key identifies the request within its authenticated tenant. -Inline retries use normalized effective configuration; new saved-Agent references -record caller intent independently of later source updates/deletion. Retries do -not admit initial input again. Every retry must match the original typed creator, -including across key rotation. Keys in the same Project share the creator principal, -so rotating a key preserves that retry identity. Records with a known creator but -no recorded request intent retain resolved-snapshot behavior; records without a creator cannot be retried. -These retry policies are not verified hosted semantics. See the -[retry boundary](../../contracts/agents-api/README.md#public-semantics). - -The Store uses internal creation keys and preserves immutable engine/configuration, -native continuity and same-tenant device bindings. The public API applies schema -validation/defaults before storage. Internal bounds are 64 KiB for metadata and -512 KiB for configuration. Keep credentials out of both. Public metadata permits -at most 16 string pairs, 64-character keys and 512-character values; storage bounds -do not replace those rules. Violations and non-string values return -`invalid_request_error` with a `metadata` or `metadata.` param. PostgreSQL -cannot store U+0000, so requests containing it in any stored string return 400 -before anything is written; this is a local limit, not hosted parity. Tenant identity comes from authenticated credentials, -never metadata or a caller-supplied business identity. - -## Internal Turn persistence - -Validated message/cancel/function-result batches commit under a tenant-scoped -Session lock. Messages start a Turn when idle and steer active work. Retry keys -identify the whole ordered batch; an invalid event does not partially admit it. -Queued cancellation needs no live engine. Active cancellation awaits a native -outcome, and completion may win the race. Terminal states cannot be overwritten. - -A Session keeps its effective configuration, engine and device across Turns; -product Conversations, native Sessions, connections, processes and sandboxes are -different objects. Strict native resume requires retained history on that device. -The API owns durable public history and pending function decisions; adapters own -native translation and their harness owns the model/tool loop. - -The worker uses its database lease connection for execution writes. Lease loss -fences those writes; it does not prove native commands or side effects have stopped. -Restart conservatively fails previously claimed work and retains queued work. -Uncertain delivery is never blindly replayed. Function actions and live SSE are -available within the [current coverage](../../contracts/agents-api/README.md); -other pending interactions, process-loss recovery and environment lifecycle remain -incomplete. Durable acceptance is not an exactly-once side-effect guarantee. - -## Standalone HTTP service - -Run migrations first, then `go run ./services/agents-api/cmd/server`. The service -uses `OAC_DATABASE_URL` for its dedicated database; it does not read the -product database or accept product login cookies. It requires the Core key digest -file named by `OAC_CORE_KEY_DIGESTS_FILE` at startup; the Core key -authenticates `/core/v1`, where Projects and keys are managed. The Core key cannot -authenticate `/v1`, and application API keys cannot authenticate `/core/v1`. - -Projects and API keys live only in the database. Configuration files contain -infrastructure settings and deployment credentials, not business identities. -Installation creates no Project or application key. Using the -[administrator API](../../contracts/agents-api/admin-api.md), create a Project with -`POST /core/v1/projects` and issue a named key with -`POST /core/v1/projects/{project_id}/keys`, or use Core Web's **Projects and -keys** page. Both requests accept a JSON object containing `name`; Core generates -the identifiers. - -A Project owns one tenant and one execution principal. All keys in it have equal -access to its assets and share that principal; write provenance records the actual -key separately. Issuance returns plaintext once, and the database stores its digest. -Deliver the secret only to authorized applications. Rotate by issuing another key -in the same Project and revoking the old one. No secret-reset endpoint or service -restart is needed. Renaming a Project preserves its ID, principal and assets. -Archiving disables all its keys but retains assets and already accepted execution. -Administrators can read or delete retained resources. - -Optional `OpenAI-Organization` and `OpenAI-Project` headers must match the Project's -execution scope; repeated or conflicting values fail authentication. The catalog -Project UUID and its external execution-scope identifier are distinct; see the -administrator contract. These identities grant no product-user rights. New Sessions -persist the Project principal as creator atomically and never change it on retry. -Historical Sessions keep unknown creators and remain readable; no key, metadata or -product record can assign their ownership through a retry. Retire older API writers -before starting this deployment; mixed-version creation is unsupported. Executor -credentials separately match this recorded creator before authorizing an Environment -connection; they do not inherit general caller API permissions. Native Runtime and -node transport contracts are unchanged. - -`OAC_ADDR` defaults to `127.0.0.1:8091`; use a TLS reverse proxy for remote -access. `OAC_DEFAULT_HARNESS` defaults to `codex`; use `claude_sdk` for Claude Code -or `mcode` for MiniMax Code. Configure the corresponding qualified Runtime through -its [deployment guide](../../contracts/agents-api/README.md#public-engine-profiles). -It selects new Sessions independently of the requested -model. Existing Sessions retain their stored engine. Set -`OAC_HARNESSES=codex,claude_sdk,mcode` to explicitly enable installed profiles -for user-managed enrollment without a managed Provider. This list supplements the -default engine and any managed engine profiles; unknown names fail startup. -Enabling a profile does not install its harness or qualify its deployment. - -The SDK base URL is `http://127.0.0.1:8091/v1`. Requests require a bearer key. -Agents and Vault routes also require `OpenAI-Beta: agents=v1` (set by their SDK -resources); general Files routes do not. Supported operations include: - -- Saved Agent create/retrieve/update/list/delete. -- Session create/retrieve/list/delete and metadata-only update. Creation supports inline - configuration or a saved `agent_id`, field replacements, initial text - and ordinary or streaming responses. Initial input is required for `none` and - streamed creation outside `self_hosted`. -- Session event submission and live streaming, Turn retrieve/list and Items list. -- Environment retrieve for three-harness colocated self-hosted and Docker - profiles, bounded live file listing, and inline/source copies into qualified - local workspaces. Shared Artifacts support capture, list/retrieve/content and - deletion independently of the live Runtime after publication. -- Project-owned `user_data` source file upload/list, metadata/content retrieval and - deletion; see [source Files](../../contracts/agents-api/source-files.md). -- Vault create/retrieve/list/delete, project-scoped pagination and stored status - filtering; static-bearer and OAuth Credential create/retrieve/list/replacement/delete, - plus [dispatch-time OAuth refresh](oauth-credentials.md). Public archive semantics remain gaps. Already-delivered credentials - are not withdrawn by local deletion. Session attachments support - [authenticated HTTPS MCP](credentials.md#use-a-credential-in-a-session). - -Execution uses the selected -[engine profile](../../contracts/agents-api/README.md#public-engine-profiles), -including `none` and the colocated self-hosted profile described below. -Ordinary JSON requests have a 1 MiB body limit; file transfers use the separate -bounds in the Files contracts. Session lists support `after`, `limit` (0 is treated -as 1 and values above 100 as 100), -`order` (`asc`/`desc`) and optional immutable root `agent_id`. The local defaults -are 20 and descending order; exact hosted limits/error semantics remain unverified. -Session updates require the metadata field; null/empty clears it and an object -replaces supplied pairs. An empty update body rejects before resource lookup. - -Delete with `client.beta.agents.sessions.delete(session.id)`. Only a durably idle -or failed Session without required actions or pending input can be deleted; a -queued, running or waiting root Turn or a pending input reservation returns 409 -`conflict_error` and leaves the Session unchanged. Subagent child Turns and -pending Environment file writes do not block deletion. Cancel its work -with an `agent.session.input.cancel` event, wait until it is idle, then delete it. -Confirmation means public removal: Session/history reads and new input become -unavailable, and existing streams close on observing removal. Creation keys stay -reserved; deletion never affects other Sessions, saved Agents or their shared -device. Internal records are retained for execution settlement. Managed Docker -deletion separately revokes authority and reclaims owned compute/workspace/history; -caller-managed compute is not reclaimed by this service. Repeating the deletion of -your own deleted Session returns the same confirmation, missing and foreign -Sessions return 404, and creation-key reuse returns 409; overlapping stream timing -is unverified. - -Codex command Items support live `agent.output.command_execution_output.delta` -events when emitted by the connected daemon. Queries retain accumulated drafts and -authoritative completion snapshots, including observed partial output after -cancellation. Native text conversion/output quotas apply; older peers may provide -only completion snapshots. Pinned native 0.153.4 may also omit early process -output from both notifications and its final aggregate; this remains an upstream -execution gap. Recover missed output with Items queries, not SSE replay. - -Non-text message input, Subagents and installations beyond hosted initial files, -env, ordered setup and npm/Python packages remain unsupported. Saving optional Agent configuration does not make -it executable. Unsupported requests fail explicitly. `/healthz` reports liveness only. - -## Managed hosted execution - -The basic `openai_hosted` profiles for Codex, Claude Code and MiniMax Code require -explicit operator configuration. Select the qualified native image using the -[engine profile guides](../../contracts/agents-api/README.md#public-engine-profiles), -then follow the [Docker setup](deploy/codex/README.md#standalone-operator-configuration). -Core-managed hosting supports deployment-selected E2B, Docker or microsandbox; -see the [nodes guide](../../docs/getting-started/nodes.md). For the separate -user-managed E2B path, see -[E2B Runtime packaging](deploy/e2b/README.md). -Core remains independently deployed with its own database. Public idle and initial -text Sessions share the existing preparation, execution, Files and recovery paths. -Networking defaults to enabled; disabled and exact-domain restricted policy are -supported after setup. System/npm/Python packages use the shared initializer; -remaining unsupported combinations are explicit gaps. Initial inline/file_id files, confidential env, npm/Python packages, -ordered setup and [public Environment Templates](../../contracts/agents-api/environment-templates.md) -resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses -require separate integration and qualification. -Connected describes the authenticated Runtime connection, not native readiness. -A provisioning failure fails the Session with a safe step and exit-status reason -([initialization failure](../../contracts/agents-api/environment-templates.md#initialization-failure--september-23)); -other exact hosted failure and expiry semantics remain unverified. - -The independent Docker Provider consumes an immutable Runtime image and retains -one caller-owned allocation reference through partial creation and cleanup. Persist -that reference before Create and serialize its lifecycle; resolve a lost response -with observed state, without rewriting bootstrap credentials or replaying startup. -Provider state is compute state, not public Environment readiness. Named Runtime -volumes need explicit owned cleanup after container removal. A second mount of the -same workspace volume subdirectory provides the public `/workspace` path to native -tools; trusted staging and atomic rename retain the original parent mount. Do not -copy files or widen private-path reads to preserve an alias. Initialization command -timeouts can leave processes alive and require allocation cleanup before reuse. -The [managed Runtime build and operator configuration](deploy/codex/README.md#managed-runtime-image-and-docker-adapter) -defines the explicit opt-in for basic hosted admission. Building an image alone -does not qualify its isolation or enable public creation. - -## Internal execution device connection - -The standalone service can accept existing daemon connections without a Parsar -workspace or product database. Enable its internal gateway by setting -`OAC_PUBLIC_URL=https://your-service` (HTTP only for a loopback host during -local development). Core returns the derived -`wss://your-service/api/v1/agent-daemon/ws` as `self_hosted.remote_url`. It names -our private daemon transport, not stock OpenAI `exec-server` interoperability. - -After migrations, an operator can provision a device for an execution tenant: - -```bash -umask 077 -mkdir -p ~/.oac/daemon/default -go run ./services/agents-api/cmd/device \ - --tenant '' --name 'local executor' \ - --url 'http://127.0.0.1:8091' \ - > ~/.oac/daemon/default/auth.json -oac-daemon connect --profile default -``` - -The command requires `OAC_DATABASE_URL` and emits a secret profile once. -Use a new profile rather than overwriting an existing device's credentials. When -provisioning remote compute, securely transfer this file to the same profile path -on the executor. The database stores only the credential digest. API keys and -device credentials are not interchangeable. To revoke a device: - -```bash -go run ./services/agents-api/cmd/device \ - --tenant '' --revoke '' -``` - -An existing connection is retired on its next heartbeat; new connections are -rejected immediately. The internal Store binds each Session to one same-tenant -device, preserves that assignment across retries/restarts, and refuses a silent -move to another device. Revoked bindings cannot be used for dispatch. Device -connections alone do not start a Turn. Submit text, cancellation or function results -through the official Session events endpoint; the worker assigns a same-tenant host and preserves that -binding. Managed Docker has three-harness evidence. This generic device provisioning path -is for `none`; self-hosted Sessions require the dedicated enrollment below. -User-managed enrollment has a separate [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md); complete protocol semantics remain partial. See the [ownership rules](../../AGENTS.md#public-api). - -### Enable Claude SDK execution - -Build and extract the runtime archive into a fresh managed directory on a matching -executor host. The archive contains the compiled bridge and pinned production -SDK/MCP/native dependencies; Node is installed separately. Linux x64/glibc with -Node22 is the accepted platform. See the -[runtime artifact contract](../../packages/claude-sdk-adapter/README.md#runtime-artifact) -for build outputs, version checks and platform restrictions. - -```bash -make build-claude-sdk-runtime -# After extracting the matching archive into this operator-chosen directory: -export OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT="$HOME/.oac/runtimes/claude-sdk/dist/main.js" -export OAC_RUNTIME_CLAUDE_SDK_NODE="/absolute/path/to/node" -oac-daemon connect --profile default -``` - -Set `OAC_DEFAULT_HARNESS=claude_sdk` on the API service. Configure provider access in -the daemon's private native SDK environment. Runtime readiness checks versions and -startup before daemon registration; it does not validate provider credentials. -SDK state stays under the daemon profile, independently of the replaceable bundle. -A ready SDK can start the daemon without a legacy CLI. Product `claude_code` remains -separate. Managed Node installation, runtime activation and registry publication -are not supplied by these commands. - -## Official client verification - -After preparing a dedicated test database, build the server and verify it with -the official client installed from the commit in `contracts/agents-api/upstream.json`: - -```bash -python -m pip install -r services/agents-api/tests/requirements.txt -make build-agents-api -OAC_TEST_SERVER_BIN="${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core/oac-core" \ - python services/agents-api/tests/official_client.py -``` - -The test uses `OAC_TEST_DATABASE_URL`, temporary service keys and -fresh tenant IDs. The suite checks upstream and generated response schemas, retries, -ordering, tenant isolation, unsupported options and reads after a process restart, -without a model provider. It also runs the -[official Go client integration](../../packages/agents-client/README.md#go-client), using two -fresh tenants, and validates its created Sessions through the Python SDK. - -## Checks - -```bash -OAC_TEST_DATABASE_URL='postgres://.../oac_local_tests' \ - make check-agents-api -``` - -Set `OAC_TEST_OFFICIAL_SDK_PYTHON` to the fixed SDK interpreter for the Store client -fixtures, and run the separate official-client command above as well. -`TestEnvironmentRetrievalOfficialClient` verifies public creation, scoped safe -Environment reads and retrieval after reopening without execution configuration. -`TestEnvironmentInitialFailureOfficialClient` verifies failed Session reads and -matching SDK/raw live failure events after privately provisioned initial input -expires, without fabricating a Turn or changing the Environment status. It is a -persistence prerequisite test. `TestSelfHostedInitialCreationOfficialClient` -separately exercises ordinary/streamed public initial creation through the Worker, -retry identity, disconnect survival and explicitly controlled deadline failure. -The test database must be named `oac_*_tests` and contain no product -workspace tables. Tests apply only this service's migrations and use new tenant -IDs without truncating tables. Missing test configuration skips DB tests locally; -the `OpenAgentCore checks` CI workflow always supplies its own PostgreSQL service. Run the -full `make check` before review as well. Product OpenAPI generation excludes this -service; its supported HTTP contract is generated separately. - -Run `make openapi` after handler annotation changes. It reuses the original -Core-only swaggo v1.16.4 generator, then splits the result by namespace: `/v1` -into `openapi.yaml`, `/core/v1` into `core.openapi.yaml` and `/api/v1` into -`runtime.openapi.yaml`; the last two use base path `/`, and each document keeps -only the security schemes its operations use. All generated schemas remain free -of product routes. - -## Public text execution - -With the daemon gateway enabled, the service owns one worker per execution database -and processes up to four Turns concurrently. Other workers are rejected by a -PostgreSQL advisory lock. A disconnected host leaves unsent work queued; clients -may cancel it. Session/Turn/Items queries expose durable results. - -```python -from openai import OpenAI - -client = OpenAI(base_url="http://127.0.0.1:8091/v1", api_key="") -session = client.beta.agents.sessions.create( - agent={"model": ""}, - environment={"type": "none"}, - input="Hello", - extra_headers={"Idempotency-Key": "first-message"}, -) -``` - -Configure model access in the engine host's native configuration. API tenant keys -and daemon credentials authenticate this service, not a model provider. Never put -provider secrets in Session metadata. This path does not enable Parsar Skill/SP -callbacks or bypass the pending product authorization work. - -Session creation admits initial text atomically; add `stream=True` for -created/live events. Open a GET event stream before submitting -later work, or use the official `sessions.stream` helper for one Turn. Function -handlers return results through the same public events endpoint. Recover missed -output with Session/Turn/Items queries; reconnecting SSE does not replay history. -See [creation streaming](../../contracts/agents-api/README.md#session-creation-streaming) -for retry behavior and unverified hosted timing. - -The [accepted workflows](../../contracts/agents-api/README.md#acceptance-evidence-and-remaining-scope) -include real MiniMax execution through built API/daemon/Codex and Claude SDK, -function success/error, cancellation and native continuation. Controlled fixtures -remain useful but do not replace real-provider acceptance for execution changes. - -## Historical Item storage - -Migration 15 records the retirement of private journal-to-Item backfilling. It -preserves public Items and source journals and refuses unindexed historical Turns. -This is historical schema evidence, not a supported upgrade procedure. Do not set -index markers manually, replay native execution, or run an older service to convert -a database for the current release. - -Historical installations are unsupported. Preserve their database and execution -history, and install the current release separately. Fresh database initialization -continues through the ordinary migration runner. Current recovery reads use -Session/Turn/Items; they do not provide SSE replay. - -## User-managed Runtime enrollment - -V1 uses our daemon as the user-side executor. Deploy daemon, selected harness, -local tools and protected `/workspace` together using the shared Runtime. For this caller-managed path, the user owns local or E2B allocation, renewal -and destruction; use the official E2B SDK through the -[E2B guide](deploy/e2b/README.md). Deployment-managed E2B, Docker and microsandbox -are separate hosted choices in the [nodes guide](../../docs/getting-started/nodes.md). - -Create a Session with `environment={"type":"self_hosted", -"workspace_directory":"/workspace"}` and empty/default capability directories. -Retain the returned Environment ID and unchanged `remote_url`. Initial text may -wait for connection; an idle Session creates no Turn. Codex, Claude SDK and MiniMax -reuse the same exact binding and `LocalEnvironment` preparation path. Service-origin -HTTP MCP is rejected on this placement; `none` MCP and separately qualified hosted -Environment Plugin MCP remain available within their own limits. - -An operator issues the Environment's connect-only executor credential with the -Core key, through Web or -`POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` -([executor credentials](../../contracts/agents-api/environment-executor-credentials.md)), -and gives the returned credential file to the executor host. With direct database -access, the operator CLI can instead issue a principal executor key: - -```bash -umask 077 -oac-core-environment-key --tenant "$TENANT_ID" \ - --organization "$ORGANIZATION_ID" --project "$PROJECT_ID" \ - --subject-kind service_account --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" \ - > "$HOME/.oac/executor-key.json" -``` - -The immutable principal must match a verified project mapping and the Session's -recorded creator. Add `--environment "$ENVIRONMENT_ID"` to restrict a key to one -live Environment. Keep the one-time `executor_token` output private. Only its digest -is stored; there is no secret read-back. `--rotate` and `--revoke` require the same -management ID and full principal; neither changes the key's restriction. Unknown -historical creators cannot enroll. API bearer keys and executor keys are separate. - -On the executor host, save that JSON as a private -`$OAC_RUNTIME_HOME/daemon/executor-key.json`, outside the tool workspace. Use the [native installer](../../docs/getting-started/self-hosted.md) to prepare the selected -Harnesses. Managed images preinstall them. A preconfigured Runtime can connect -with the values returned by Session creation: - -```bash -oac-daemon connect --remote "$REMOTE_URL" \ - --environment-id "$ENVIRONMENT_ID" \ - --credential-file "$OAC_RUNTIME_HOME/daemon/executor-key.json" -``` - -Use TLS outside loopback. Enrollment supplies the trusted Session identity; do -not inject an unrelated `OAC_RUNTIME_SESSION_ID`. The daemon persists its -immutable Environment binding beside the key and refuses to adopt another -Environment's existing native history. Rotation keeps the same key ID: update the -protected file, then restart the daemon to authenticate with the new token. -WebSocket authentication uses the `Authorization` header, never a URL token. -On a permanent rejection (enrollment 401 or 409, a permanent WebSocket rejection -or close) `connect --environment-id` prints one message naming the fix, makes no -further requests and exits 0 on SIGTERM or SIGINT; transient failures exit 1. - -The private `POST /api/v1/agent-daemon/enroll` endpoint accepts that executor bearer -and `{"environment_id":"..."}`. It returns `device_id`, `session_id`, -`environment_id` and `workspace_directory`, never another credential. Enrollment -atomically binds one dedicated device/key to the Session; retries retain it and -conflicting bindings reject. No managed `runtime_allocation` is created. Runtime -onboarding connects to the returned `remote_url` using that exact binding and key. -The endpoint is outside the pinned public Agents API, which remains unchanged. -It is not stock `exec-server`, `/cloud/environment/*` or Noise interoperability; -there is no service-side harness or remote tool forwarding compatibility path. - -The gateway and Worker recheck current credential authority. Rotation/revocation, -Session deletion and lost ownership deny further use; a replacement connection -cannot overwrite a successor's observations. Connection events report authenticated -connectivity, not native preparation readiness. Retained native history and workspace -must survive a Runtime restart for continuation; missing history fails closed. -Neither disconnect nor deletion promises immediate native process quiescence or -reclaims user-owned E2B/local compute. The user must stop and destroy it explicitly. - -Pending input retains its durable identity/deadline through HTTP disconnects. Later -idle input returns 202 after preparation/admission, not after model completion; -use client/proxy timeouts above five minutes and recover progress through events -and reads. Exact upstream failure/error timing remains unverified. - -The Runtime's shared Go workspace implementation owns directory reads, writes and -output export; Harness adapters use the same authorized workspace binding. -The [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md) -identifies fixed-SDK/raw HTTP, real-model, Files/Artifacts, cancellation, -restart/history and credential lifecycle evidence, with its recorded revision limits. - - -### HTTP MCP execution - -Public `agent.tools` declares an explicit MCP connection origin. Omitted/null -origin remains `service`. The [origin, credential and Harness matrix](../../contracts/agents-api/environments.md#public-mcp-connection-origin) -owns the supported combinations: Codex/Claude service HTTP on `none`, and -Codex/Claude/MiniMax Environment HTTP on managed or user-owned workspaces, subject -to declared native limits. Public declarations and installed Plugin MCP converge -on the same Runtime effective bindings; they retain distinct credential authority. - -Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}`; Claude SDK supports HTTP MCP with -`environment:{"type":"none"}`. Inline or saved Agent tools may declare: - -```json -{ - "type": "mcp", - "server_label": "tickets", - "transport": {"type": "http", "server_url": "https://mcp.example.com/mcp"}, - "connection_origin": "service", - "allowed_tools": ["lookup_ticket"], - "required": false -} -``` - -An omitted/null `allowed_tools` permits all tools from that server; `[]` permits -none. Native discovery may still connect to a declared server. The daemon must -advertise `mcp_http_tools`; selection waits for a capable device. The native -harness owns MCP discovery, calls and results. Public `mcp_call` Items use original -server/tool names; recover missed live events through Session, Turn and Items reads. - -With Codex, set `required:true` to require initialization before the first native Turn. It -defaults to false and additionally requires the pinned daemon's `mcp_http_required` -capability. Native root thread creation and cold resume wait for required servers; -initialization failure stops execution without replacing retained history. Public -work can already be accepted or queued during this wait. Exact hosted creation -timing/errors and continuing MCP health monitoring remain unverified. - -On `environment:none`, both Codex and Claude SDK support tenant-owned `vault_ids` -for static-bearer HTTPS MCP. `self_hosted` is explicitly unsupported for -service-origin MCP in V1, including anonymous requests. -An explicit -`credential_id` selects an attached credential for the exact HTTPS URL; omission/null -selects a unique matching credential, or stays anonymous if none matches. Ambiguity -fails before Session creation with 409 `conflict_error`. Selection is frozen -privately; Session reads and events show an implicitly selected credential ID in the -public tool, while the stored request keeps the caller's field. See -[credential setup and limits](credentials.md). -Authenticated execution additionally requires `mcp_http_bearer_auth`; missing keys -or failed authorization/decryption never fall back to anonymous execution. - -The colocated V1 `self_hosted` profile does not admit service-origin MCP. The old -separate executor/service-side MCP combination and its remote capability gates are -retired. Do not forward Vault credentials to user-owned Runtime compute. - -Claude SDK requires a packaged runtime that reports `mcp_http_tools`; the SDK -version alone does not qualify an older bundle. Its current profile -accepts either `required` value, requires connected servers and static inventories. Server labels -accept ASCII letters, digits, underscore and hyphen, except reserved `functions`; -selected tool names additionally accept dots. Declared HTTP MCP tools compose with -host functions; undeclared servers, built-ins and subagents remain disabled. -Authenticated requests also require `mcp_http_bearer_auth`. The existing Vault -selection rules apply, including implicit exact-URL matches and immutable private -bindings. Per-server/per-launch environment references keep bearer values out of -native argv and stored state. A Vault with no matching credential may stay anonymous. -Anonymous requests suppress native OAuth/credential injection with a blank -Authorization header, without deleting native state. Servers rejecting that header, normalized -name collisions, changing inventories and original MCP metadata fidelity remain -gaps. Items retain the observed native JSON, which may differ from the original -MCP envelope. See the [Claude SDK profile](../../packages/claude-sdk-adapter/README.md#http-mcp). - -The current subset rejects native OAuth login, inline authorization, nonempty headers or -request metadata, URL userinfo/query/fragment, the `environment` origin, stdio -and engines other than Codex/Claude SDK. The Codex adapter also -rejects reserved native labels and stored native MCP credentials. It verifies -exact effective MCP configuration before starting/resuming a native thread, -excludes undeclared servers and disables native apps/plugins. This runs on trusted -service compute; it does not provide filesystem isolation or guard against -concurrent operator configuration mutation. These limits are implementation gaps, -not changes to the pinned official protocol. - -A dedicated local Runtime uses one Environment-scoped device credential and an -immutable binding to that Environment's Session. It is excluded from general -device selection; another Session cannot claim it, including within the same -tenant. Deleting its Session invalidates credential lookup and heartbeat renewal. -Provision a new scoped device atomically rather than widening an existing shared -device credential. Revocation does not authorize silent placement replacement. - -The private local Environment reference contains its identity and, for policy-aware -execution, its immutable network policy. Trusted Runtime deployment configuration -freezes the Environment, Session and workspace root; -requests cannot supply a replacement root. The V1 path uses only the exact local -Environment reference. Use the same preparation/start lifecycle for native execution and the -existing bounded workspace controls for directory access. Local idle directory -reads use the common Go filesystem implementation without a temporary Harness. These private capabilities alone do not authorize public requests or establish -Provider lifecycle. Self-hosted enrollment supplies the exact local binding. -Core rechecks the persisted Environment/device binding for preparation and active -reads; capability discovery cannot select or authorize a general device for this -placement. Local work uses the existing pending-input reservation and Worker -ownership without a remote connection resolver. The hosted profile supports `network.access: enabled`, `disabled` and exact-host -`restricted`; each image must qualify the supported policies before public deployment. Omitted -network settings mean enabled upstream and must not be silently treated as disabled. - -User-managed Runtime enrollment authenticates the existing principal executor key -against the exact live Session/Environment and its recorded creator. Keys retain -stable management IDs, immutable principals, optional exact-Environment restrictions, -rotation/revocation and digest-only storage. They grant connection authority, never -Session API access. No raw-token import or secret read-back is added. - -Enrollment atomically creates or recovers one dedicated device and immutable Session -binding under the Session lock. The frozen workspace and capability directory selection come from the Session -configuration and must match the local binding. Runtime snapshots declared local -Skill or Plugin directories before creating the native executor. No `runtime_allocation` is -created for user-owned compute. A retry cannot replace a device, change its bound -key or adopt another native history. Gateway authentication and dispatch recheck -current key authority; rotation/revocation and deletion deny further use. - -The daemon, selected harness, local tools and workspace run together. The Dispatcher -passes the existing typed `LocalEnvironment` after exact tenant/Session/Environment/ -device checks. Native preparation, Files and Artifacts reuse the same protected -local workspace and existing lifecycle owners. There is no registry/Noise relay, -transient harness credential, service-side harness or remote tool forwarding path. -Keep model credentials, daemon authorization and native histories private; connection -success alone establishes neither native readiness nor filesystem isolation. - -## Hosted sandbox nodes - -The release includes `oac-node` for local and remote hosts. Add nodes with -Web's one-command flow in the [nodes guide](../../docs/getting-started/nodes.md), which -also covers provider selection, manual registration and reset. - -Implementation rules for hosted sandbox nodes and optional suspension are in -[Agents API implementation constraints](IMPLEMENTATION.md#hosted-sandbox-nodes-and-optional-suspension). diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md deleted file mode 100644 index 308933236..000000000 --- a/services/agents-api/RELEASE.md +++ /dev/null @@ -1,47 +0,0 @@ -# Standalone Core archive - -This Linux amd64 archive holds Core alone: the API server `oac-core`, its migrator `oac-core-migrate`, and the operator commands `oac-core-device`, `oac-core-environment-key` and `oac-node`. It has no Web console, installer or `oac` command, and it needs your own PostgreSQL. To install Core with Web and nodes, use the [installation guide](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/getting-started/install.md). - -## Verify and extract - -Verify the archive checksum supplied with the package, then extract it into a new directory under `~/.oac/`. Keep configuration outside the extracted package so that replacing the binaries does not replace credentials or state. - -```sh -sha256sum -c @ARCHIVE_NAME@.tar.gz.sha256 -mkdir -p "$HOME/.oac/releases" -tar -xzf @ARCHIVE_NAME@.tar.gz -C "$HOME/.oac/releases" -cd "$HOME/.oac/releases/@ARCHIVE_NAME@" -sha256sum -c SHA256SUMS -core_bin_dir="$PWD/bin" -``` - -`manifest.json` records the source commit and tree, the platform, the Go version, the pinned upstream protocol and the binary hashes. Checksums detect changed bytes; obtain the archive and its checksum from a trusted source. - -## Configure and start - -Create a dedicated PostgreSQL database and account. Generate a random Core key, keep it in private storage, and write its lowercase hex SHA-256 digest as a JSON array to `core-key-digests.json`: - -```sh -umask 077 -core_config_dir="$HOME/.oac/oac-core-deployment" -mkdir -p "$core_config_dir" -export OAC_DATABASE_URL='postgres://:@/' -export OAC_CORE_KEY_DIGESTS_FILE="$core_config_dir/core-key-digests.json" -export OAC_ADDR=127.0.0.1:8091 -export OAC_PUBLIC_URL=http://127.0.0.1:8091 -``` - -`OAC_DATABASE_URL` and `OAC_CORE_KEY_DIGESTS_FILE` are required. `OAC_PUBLIC_URL` is the origin that applications and machines use to reach Core: an HTTPS origin, or plain HTTP on loopback only. The [configuration reference](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. Run the migrations, then start Core in the foreground or under your own supervisor: - -```sh -"$core_bin_dir/oac-core-migrate" -"$core_bin_dir/oac-core" -``` - -`GET /healthz` reports liveness. One Core process serves each database; replicas add no availability. Keep the database when you replace the binaries. Put a TLS reverse proxy in front for remote clients. - -## Next steps - -- Use the Core key with the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/contracts/agents-api/admin-api.md) to create a Project and issue its API key, then follow the [quickstart](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/getting-started/quickstart.md). -- To run Sessions on your own machines, follow the [self-hosted guide](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/getting-started/self-hosted.md). The one-command installation needs the native installer catalog of the same commit: set `OAC_NATIVE_INSTALLER_DIR` to the `native-installers` directory of that commit's Core distribution, as the [configuration reference](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/configuration.md#appendix-core-environment-without-the-installer) describes. The [executor credential contract](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/contracts/agents-api/environment-executor-credentials.md) covers issuing credentials with the Core key and `oac-core-environment-key`. -- To add sandbox nodes with `oac-node`, see the [node guide](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/docs/getting-started/nodes.md). diff --git a/services/agents-api/cmd/device/main.go b/services/agents-api/cmd/device/main.go deleted file mode 100644 index 76472514e..000000000 --- a/services/agents-api/cmd/device/main.go +++ /dev/null @@ -1,76 +0,0 @@ -// Command device provisions or revokes an execution device using operator DB access. -package main - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "net/url" - "os" - "strings" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func main() { - if err := run(); err != nil { - log.Bg().Error("execution device provisioning failed", "error", err) - os.Exit(1) - } -} - -func run() error { - tenant := flag.String("tenant", "", "execution tenant UUID") - name := flag.String("name", "", "device label") - serverURL := flag.String("url", "", "Agents API HTTP base URL") - revoke := flag.String("revoke", "", "revoke this device UUID instead of provisioning") - flag.Parse() - dsn, err := databaseurl.FromEnvironment() - if err != nil { - return err - } - if dsn == "" || *tenant == "" || flag.NArg() != 0 { - return errors.New("OAC_DATABASE_URL and --tenant are required") - } - if *revoke == "" { - u, err := url.Parse(*serverURL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { - return errors.New("--url must be an absolute http(s) base URL without a path or credentials") - } - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - return errors.New("invalid execution database configuration") - } - defer pool.Close() - s := store.New(pool) - if *revoke != "" { - return s.RevokeDevice(ctx, *tenant, *revoke) - } - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return err - } - credential := base64.RawURLEncoding.EncodeToString(secret) - registered, err := s.CreateDevice(ctx, *tenant, *name, device.HashCredential(credential)) - if err != nil { - return err - } - // Emit the existing daemon profile shape. Operators redirect this secret to a - // mode-0600 auth.json under ~/.oac; it is never included in diagnostic logs. - return json.NewEncoder(os.Stdout).Encode(map[string]string{ - "server_url": strings.TrimRight(*serverURL, "/") + "/api/v1", "runtime_id": registered.ID, - "runner_credential": credential, "device_name": registered.Name, - }) -} diff --git a/services/agents-api/cmd/environment-key/main.go b/services/agents-api/cmd/environment-key/main.go deleted file mode 100644 index 47f5df608..000000000 --- a/services/agents-api/cmd/environment-key/main.go +++ /dev/null @@ -1,143 +0,0 @@ -// Command environment-key manages executor principal credentials using operator DB authority. -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "io" - "os" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" -) - -func main() { - if err := run(); err != nil && !errors.Is(err, flag.ErrHelp) { - log.Bg().Error("executor credential operation failed", "error", err) - os.Exit(1) - } -} - -type commandOptions struct { - principal identity.Principal - keyID string - environment string - rotate bool - revoke bool -} - -func parseOptions(args []string, helpOutput io.Writer) (commandOptions, error) { - var options commandOptions - flags := flag.NewFlagSet("oac-core-environment-key", flag.ContinueOnError) - flags.SetOutput(io.Discard) - flags.StringVar(&options.principal.TenantID, "tenant", "", "execution tenant UUID with an existing project mapping") - flags.StringVar(&options.principal.OrganizationID, "organization", "", "execution organization ID") - flags.StringVar(&options.principal.ProjectID, "project", "", "execution project ID") - flags.StringVar(&options.principal.SubjectKind, "subject-kind", "", "executor principal kind: user or service_account") - flags.StringVar(&options.principal.SubjectID, "subject-id", "", "executor principal subject ID") - flags.StringVar(&options.keyID, "key-id", "", "canonical nonzero executor key UUID") - flags.StringVar(&options.environment, "environment", "", "optional existing Environment UUID restriction for issuance only") - flags.BoolVar(&options.rotate, "rotate", false, "explicitly replace the existing credential, including a revoked credential") - flags.BoolVar(&options.revoke, "revoke", false, "revoke the existing credential without issuing a secret") - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - flags.SetOutput(helpOutput) - flags.PrintDefaults() - return commandOptions{}, flag.ErrHelp - } - return commandOptions{}, errors.New("invalid executor credential arguments; use --help") - } - if flags.NArg() != 0 || (options.rotate && options.revoke) { - return commandOptions{}, errors.New("positional arguments are not accepted; --rotate and --revoke are mutually exclusive") - } - if options.principal.TenantID == "" || options.principal.OrganizationID == "" || - options.principal.ProjectID == "" || options.principal.SubjectKind == "" || - options.principal.SubjectID == "" || options.keyID == "" { - return commandOptions{}, errors.New("--tenant, --organization, --project, --subject-kind, --subject-id and --key-id are required") - } - if err := options.principal.Validate(); err != nil { - return commandOptions{}, err - } - if !canonicalUUID(options.keyID) { - return commandOptions{}, errors.New("--key-id must be a canonical nonzero UUID") - } - environmentSet := false - flags.Visit(func(f *flag.Flag) { - if f.Name == "environment" { - environmentSet = true - } - }) - if environmentSet && (options.rotate || options.revoke) { - return commandOptions{}, errors.New("--environment is only accepted for issuance; rotation and revocation retain the stored restriction") - } - if environmentSet && !canonicalUUID(options.environment) { - return commandOptions{}, errors.New("--environment must be a canonical nonzero UUID") - } - return options, nil -} - -func canonicalUUID(value string) bool { - id, err := uuid.Parse(value) - return err == nil && id != uuid.Nil && id.String() == value -} - -func run() error { - options, err := parseOptions(os.Args[1:], os.Stderr) - if err != nil { - return err - } - dsn, err := databaseurl.FromEnvironment() - if err != nil { - return err - } - if dsn == "" { - return errors.New("OAC_DATABASE_URL must point to a dedicated execution database") - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - return errors.New("invalid execution database configuration") - } - defer pool.Close() - s := store.New(pool) - if options.revoke { - return credentialOperationError(s.RevokeExecutorCredential(ctx, options.principal, options.keyID)) - } - var credential store.IssuedExecutorCredential - if options.rotate { - credential, err = s.RotateExecutorCredential(ctx, options.principal, options.keyID) - } else { - credential, err = s.IssueExecutorCredential(ctx, options.principal, options.keyID, options.environment) - } - if err != nil { - return credentialOperationError(err) - } - // Operators redirect stdout to a mode-0600 file under ~/.oac; no read-back operation exists. - if err := json.NewEncoder(os.Stdout).Encode(credential); err != nil { - return errors.New("could not write issued executor credential; rotate explicitly to replace it") - } - return nil -} - -func credentialOperationError(err error) error { - switch { - case err == nil: - return nil - case errors.Is(err, store.ErrExecutorCredentialExists): - return store.ErrExecutorCredentialExists - case errors.Is(err, store.ErrNotFound): - return errors.New("executor principal project mapping or authorized credential target not found") - case errors.Is(err, store.ErrInvalidInput): - return errors.New("invalid executor credential identity or target") - default: - return errors.New("executor credential database operation failed") - } -} diff --git a/services/agents-api/cmd/environment-key/main_test.go b/services/agents-api/cmd/environment-key/main_test.go deleted file mode 100644 index 30d6a547b..000000000 --- a/services/agents-api/cmd/environment-key/main_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "flag" - "fmt" - "io" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -const ( - testTenant = "471e90e1-d9b3-418b-b339-928284514ae1" - testKey = "581d17e2-f063-42dc-b979-3fbd1c7a052c" - testEnvironment = "5c9751a6-df59-4612-912e-55e6a7898c5a" -) - -func principalArgs() []string { - return []string{ - "--tenant", testTenant, - "--organization", "test-org", - "--project", "test-project", - "--subject-kind", "service_account", - "--subject-id", "test-executor", - "--key-id", testKey, - } -} - -func TestParseOptionsPrincipalCredentialOperations(t *testing.T) { - for _, test := range []struct { - name string - args []string - environment string - rotate bool - revoke bool - }{ - {name: "principal issuance"}, - {name: "exact issuance", args: []string{"--environment", testEnvironment}, environment: testEnvironment}, - {name: "rotation", args: []string{"--rotate"}, rotate: true}, - {name: "revocation", args: []string{"--revoke"}, revoke: true}, - } { - t.Run(test.name, func(t *testing.T) { - options, err := parseOptions(append(principalArgs(), test.args...), io.Discard) - if err != nil { - t.Fatal(err) - } - if options.principal.TenantID != testTenant || options.principal.OrganizationID != "test-org" || - options.principal.ProjectID != "test-project" || options.principal.SubjectKind != "service_account" || - options.principal.SubjectID != "test-executor" { - t.Fatalf("unexpected principal: %+v", options.principal) - } - if options.keyID != testKey || options.environment != test.environment || options.rotate != test.rotate || options.revoke != test.revoke { - t.Fatalf("unexpected operation: %+v", options) - } - }) - } - options, err := parseOptions(append(principalArgs(), "--subject-kind", "user"), io.Discard) - if err != nil || options.principal.SubjectKind != "user" { - t.Fatalf("user principal rejected: %v", err) - } -} - -func TestParseOptionsRequiresEveryIdentityFlag(t *testing.T) { - for _, name := range []string{"--tenant", "--organization", "--project", "--subject-kind", "--subject-id", "--key-id"} { - t.Run(name, func(t *testing.T) { - args := principalArgs() - for i := 0; i < len(args); i += 2 { - if args[i] == name { - args = append(args[:i], args[i+2:]...) - break - } - } - if _, err := parseOptions(args, io.Discard); err == nil || !strings.Contains(err.Error(), name) { - t.Fatalf("missing %s was not clearly rejected: %v", name, err) - } - }) - } - if _, err := parseOptions([]string{"--tenant", testTenant, "--environment", testEnvironment}, io.Discard); err == nil { - t.Fatal("legacy exact-Environment arguments were accepted") - } -} - -func TestParseOptionsRejectsInvalidIdentityAndRestrictionChanges(t *testing.T) { - for _, test := range []struct { - name string - args []string - }{ - {name: "conflicting operations", args: []string{"--rotate", "--revoke"}}, - {name: "rotation with restriction", args: []string{"--rotate", "--environment", testEnvironment}}, - {name: "revocation with restriction", args: []string{"--revoke", "--environment", testEnvironment}}, - {name: "rotation with empty restriction", args: []string{"--rotate", "--environment="}}, - {name: "revocation with empty restriction", args: []string{"--revoke", "--environment="}}, - {name: "empty issuance restriction", args: []string{"--environment="}}, - {name: "invalid environment", args: []string{"--environment", "invalid"}}, - {name: "noncanonical environment", args: []string{"--environment", strings.ToUpper(testEnvironment)}}, - {name: "zero environment", args: []string{"--environment", "00000000-0000-0000-0000-000000000000"}}, - {name: "invalid key", args: []string{"--key-id", "invalid"}}, - {name: "noncanonical key", args: []string{"--key-id", strings.ToUpper(testKey)}}, - {name: "zero key", args: []string{"--key-id", "00000000-0000-0000-0000-000000000000"}}, - {name: "noncanonical tenant", args: []string{"--tenant", strings.ToUpper(testTenant)}}, - {name: "empty organization", args: []string{"--organization", ""}}, - {name: "whitespace project", args: []string{"--project", " test-project"}}, - {name: "invalid subject kind", args: []string{"--subject-kind", "device"}}, - {name: "empty subject", args: []string{"--subject-id", ""}}, - } { - t.Run(test.name, func(t *testing.T) { - if _, err := parseOptions(append(principalArgs(), test.args...), io.Discard); err == nil { - t.Fatal("invalid arguments were accepted") - } - }) - } -} - -func TestParseOptionsRedactsValuesAndPreservesHelp(t *testing.T) { - const secret = "private-value-that-must-not-be-logged" - for _, args := range [][]string{ - {"--" + secret}, - {"--rotate=" + secret}, - {"--key-id", secret}, - {secret}, - } { - var output bytes.Buffer - _, err := parseOptions(append(principalArgs(), args...), &output) - if err == nil || strings.Contains(err.Error(), secret) || output.Len() != 0 { - t.Fatalf("invalid arguments were not safely rejected: %v; output: %q", err, output.String()) - } - } - var help bytes.Buffer - if _, err := parseOptions([]string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { - t.Fatalf("unexpected help result: %v", err) - } - if !strings.Contains(help.String(), "-key-id") || !strings.Contains(help.String(), "-subject-kind") { - t.Fatalf("help is missing principal credential arguments: %s", help.String()) - } -} - -func TestCredentialOperationErrorsDoNotExposeDatabaseValues(t *testing.T) { - const secret = "postgres://operator:private-password@database/execution" - for _, err := range []error{ - errors.New(secret), - fmt.Errorf("%w: %s", store.ErrInvalidInput, secret), - fmt.Errorf("%w: %s", store.ErrNotFound, secret), - fmt.Errorf("%w: %s", store.ErrExecutorCredentialExists, secret), - } { - redacted := credentialOperationError(err) - if redacted == nil || strings.Contains(redacted.Error(), secret) { - t.Fatalf("database failure was not redacted: %v", redacted) - } - } - if err := credentialOperationError(nil); err != nil { - t.Fatalf("successful revocation returned an error: %v", err) - } - if !errors.Is(credentialOperationError(store.ErrExecutorCredentialExists), store.ErrExecutorCredentialExists) { - t.Fatal("duplicate key guidance was lost") - } -} diff --git a/services/agents-api/cmd/migrate/main.go b/services/agents-api/cmd/migrate/main.go deleted file mode 100644 index b9c53f910..000000000 --- a/services/agents-api/cmd/migrate/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "errors" - "os" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" -) - -func main() { - if err := run(); err != nil { - log.Bg().Error("oac-core migration failed", "error", err) - os.Exit(1) - } -} - -func run() error { - databaseURL, err := databaseurl.FromEnvironment() - if err != nil { - return err - } - if databaseURL == "" { - return errors.New("OAC_DATABASE_URL must point to a dedicated execution database") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - return migrations.Apply(ctx, databaseURL) -} diff --git a/services/agents-api/cmd/sandbox-node/generations.go b/services/agents-api/cmd/sandbox-node/generations.go deleted file mode 100644 index f6d09313d..000000000 --- a/services/agents-api/cmd/sandbox-node/generations.go +++ /dev/null @@ -1,316 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "reflect" - "strconv" - "strings" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" -) - -func runGenerations(ctx context.Context, configFile, stateDir string) error { - root := filepath.Dir(configFile) - if stateDir != filepath.Join(root, "state", "node") { - return errors.New("generation state must belong to the installed node root") - } - stored, err := node.RefreshIdentity(ctx, stateDir) - if err != nil { - return err - } - base, err := providerconfig.Load(configFile) - if err != nil { - return err - } - if base.InstallationID != stored.Identity.InstallationID || base.Provider != stored.Identity.Provider || base.Generation != stored.Identity.DeploymentGeneration || base.Specification.Digest(base.Provider) != stored.Identity.SpecificationDigest { - return sandbox.ErrOwnership - } - paths, err := filepath.Glob(filepath.Join(stateDir, "generations", "*.json")) - if err != nil { - return err - } - pending, err := filepath.Glob(filepath.Join(stateDir, "generations", "*.preparing")) - if err != nil { - return err - } - paths = append(append([]string{configFile}, paths...), pending...) - values := map[uint64]node.GenerationProvider{} - recovery := []sandbox.GenerationReference{} - collection := []sandbox.GenerationReference{} - seen := map[uint64]providerconfig.Config{} - closeValues := func() { - for _, v := range values { - if v.Close != nil { - v.Close() - } - } - } - for _, path := range paths { - var config providerconfig.Config - if strings.HasSuffix(path, ".preparing") { - journal, readErr := readGenerationJournal(path) - err = readErr - if err == nil && journal.Configuration == nil { - err = sandbox.ErrOwnership - } - if err == nil { - config = *journal.Configuration - } - } else { - config, err = providerconfig.Load(path) - } - if err != nil { - closeValues() - return err - } - if config.InstallationID != base.InstallationID || config.Provider != base.Provider { - closeValues() - return sandbox.ErrOwnership - } - if path != configFile && strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), ".json"), ".preparing") != strconv.FormatUint(config.Generation, 10) { - closeValues() - return sandbox.ErrOwnership - } - state, err := generationLocalState(config, stateDir) - if err != nil { - closeValues() - return err - } - if state == "dropped" { - continue - } - if previous, ok := seen[config.Generation]; ok { - if !sameGenerationPlan(previous, config) { - closeValues() - return sandbox.ErrOwnership - } - continue - } - seen[config.Generation] = config - if state == "preparing" { - recovery = append(recovery, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) - continue - } - if state == "collecting" { - collection = append(collection, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) - continue - } - value, err := buildGeneration(config, stateDir) - if errors.Is(err, os.ErrNotExist) { - recovery = append(recovery, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) - continue - } - if err != nil { - closeValues() - return err - } - values[config.Generation] = value - } - helper := filepath.Join(root, "generation-preparer.pyz") - runHelper := func(ctx context.Context, action string, generation uint64, digest string) error { - command := exec.CommandContext(ctx, "python3", helper, "--installation-id", base.InstallationID, "--generation-action", action, "--generation", strconv.FormatUint(generation, 10), "--specification-digest", digest) - command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - // Only the preparation/collection process group is canceled. Native sandbox - // helpers retain their independent allocation-lock completion semantics. - command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } - command.Stdout = os.Stderr - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - return generationHelperError(ctx, err) - } - return nil - } - initial := make([]node.GenerationProvider, 0, len(values)) - for _, v := range values { - initial = append(initial, v) - } - manager, err := node.NewGenerationManager(ctx, node.GenerationManagerOptions{Initial: initial, Recover: recovery, Collect: collection, - Prepare: func(ctx context.Context, generation uint64, digest string) (node.GenerationProvider, error) { - if err := runHelper(ctx, "prepare", generation, digest); err != nil { - return node.GenerationProvider{}, err - } - config, err := providerconfig.Load(filepath.Join(stateDir, "generations", strconv.FormatUint(generation, 10)+".json")) - if err != nil { - return node.GenerationProvider{}, err - } - if config.InstallationID != base.InstallationID || config.Provider != base.Provider || config.Generation != generation || config.Specification.Digest(config.Provider) != digest { - return node.GenerationProvider{}, sandbox.ErrOwnership - } - value, err := buildGeneration(config, stateDir) - if err != nil { - return value, err - } - return value, nil - }, - Remove: func(ctx context.Context, value node.GenerationProvider) error { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - return runHelper(ctx, "collect", value.Generation, value.SpecificationDigest) - }, - }) - if err != nil { - closeValues() - return err - } - defer manager.Close() - return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) -} - -func buildGeneration(config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { - if config.Microsandbox != nil { - directory := filepath.Join(stateDir, "generations") - if err := os.MkdirAll(directory, 0700); err != nil { - return node.GenerationProvider{}, err - } - info, err := os.Lstat(directory) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - return node.GenerationProvider{}, sandbox.ErrOwnership - } - config.Microsandbox.HelperLeaseGeneration = config.Generation - config.Microsandbox.HelperLeasePath = filepath.Join(directory, strconv.FormatUint(config.Generation, 10)+".lease") - } - built, closeProvider, err := providerconfig.Build(config) - if err != nil { - return node.GenerationProvider{}, err - } - probe := built.Probe - if micro := config.Microsandbox; micro != nil { - probe = func(ctx context.Context) error { - if err := built.Probe(ctx); err != nil { - return err - } - command := exec.CommandContext(ctx, micro.RuntimePath, "image", "inspect", micro.Image, "--format", "json") - command.Env = append([]string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + os.Getenv("HOME")}, "MSB_BACKEND=local", "MSB_HOME="+micro.RuntimeHome, "MSB_PATH="+micro.RuntimePath, "MSB_LIBKRUNFW_PATH="+micro.FirmwarePath) - reader, err := command.StdoutPipe() - if err != nil { - return sandbox.ErrRuntimeImageUnavailable - } - if err := command.Start(); err != nil { - return sandbox.ErrRuntimeImageUnavailable - } - raw, readErr := io.ReadAll(io.LimitReader(reader, 64*1024+1)) - if len(raw) > 64*1024 { - _ = command.Process.Kill() - } - waitErr := command.Wait() - var image struct{ Digest, Architecture, OS string } - if readErr != nil || waitErr != nil || len(raw) > 64*1024 || json.Unmarshal(raw, &image) != nil || image.Digest != strings.SplitN(micro.Image, "@", 2)[1] || image.Architecture != "amd64" || image.OS != "linux" { - return sandbox.ErrRuntimeImageUnavailable - } - return nil - } - } - return node.GenerationProvider{Generation: config.Generation, SpecificationDigest: built.SpecificationDigest, Provider: built.Provider, Probe: probe, Close: closeProvider}, nil -} - -type generationJournal struct { - InstallationID string `json:"installation_id"` - Generation uint64 `json:"generation"` - SpecificationDigest string `json:"specification_digest"` - NativeComplete json.RawMessage `json:"native_complete,omitempty"` - ImportStarted json.RawMessage `json:"import_started,omitempty"` - Configuration *providerconfig.Config `json:"configuration,omitempty"` -} - -func readGenerationJournal(path string) (generationJournal, error) { - var journal generationJournal - fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) - if err != nil { - return journal, err - } - file := os.NewFile(uintptr(fd), path) - defer file.Close() - var st syscall.Stat_t - err = syscall.Fstat(fd, &st) - resolved, pathErr := filepath.EvalSymlinks(path) - if err != nil || pathErr != nil || resolved != path || st.Mode&syscall.S_IFMT != syscall.S_IFREG || st.Mode&0777 != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 || st.Size > 16384 { - return journal, sandbox.ErrOwnership - } - decoder := json.NewDecoder(io.LimitReader(file, 16385)) - decoder.DisallowUnknownFields() - err = decoder.Decode(&journal) - var trailing any - end := decoder.Decode(&trailing) - opened, statErr := file.Stat() - named, namedErr := os.Lstat(path) - if err != nil || end != io.EOF || statErr != nil || namedErr != nil || !os.SameFile(opened, named) { - return journal, sandbox.ErrOwnership - } - return journal, nil -} - -// The preparation configuration is an immutable plan, never a usable provider. -// Only Docker's two specification-proven local IDs can differ at publication. -func sameGenerationPlan(final, plan providerconfig.Config) bool { - if plan.Docker != nil && final.Docker != nil { - runtime := plan.Specification.Runtime - if final.Docker.Image != runtime.ImageID && final.Docker.Image != runtime.ImageManifestDigest { - return false - } - copyDocker := *plan.Docker - copyDocker.Image = final.Docker.Image - plan.Docker = ©Docker - } - return reflect.DeepEqual(final, plan) -} - -// Pending-only entries stay recovery/collection-only. No journal is readiness -// or authority to collect without a fresh current-connection Core grant. -func generationLocalState(config providerconfig.Config, stateDir string) (string, error) { - directory := filepath.Join(stateDir, "generations") - info, err := os.Lstat(directory) - if os.IsNotExist(err) { - return "", nil - } - if err != nil { - return "", err - } - owner, ok := info.Sys().(*syscall.Stat_t) - resolved, err := filepath.EvalSymlinks(directory) - if err != nil || resolved != directory || !info.IsDir() || info.Mode().Perm() != 0700 || !ok || owner.Uid != uint32(os.Getuid()) { - return "", sandbox.ErrOwnership - } - state := "" - for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { - path := filepath.Join(directory, strconv.FormatUint(config.Generation, 10)+suffix) - journal, err := readGenerationJournal(path) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil || journal.InstallationID != config.InstallationID || journal.Generation != config.Generation || journal.SpecificationDigest != config.Specification.Digest(config.Provider) { - return "", sandbox.ErrOwnership - } - if suffix == ".preparing" { - if len(journal.NativeComplete) != 0 || (string(journal.ImportStarted) != "true" && string(journal.ImportStarted) != "false") || journal.Configuration == nil || !sameGenerationPlan(config, *journal.Configuration) { - return "", sandbox.ErrOwnership - } - } else if len(journal.ImportStarted) != 0 || journal.Configuration != nil || suffix == ".collecting" && string(journal.NativeComplete) != "true" && string(journal.NativeComplete) != "false" || suffix == ".dropped" && len(journal.NativeComplete) != 0 { - return "", sandbox.ErrOwnership - } - state = strings.TrimPrefix(suffix, ".") - } - return state, nil -} - -// Exit 65 is reserved by the private preparer for artifact transfer/provenance. -// Arbitrary provider output never selects a wire diagnostic. -func generationHelperError(ctx context.Context, err error) error { - if ctx.Err() != nil { - return ctx.Err() - } - var exit *exec.ExitError - if errors.As(err, &exit) && exit.ExitCode() == 65 { - return sandbox.ErrRuntimeDownloadFailed - } - return errors.New("local generation operation did not complete") -} diff --git a/services/agents-api/cmd/sandbox-node/generations_test.go b/services/agents-api/cmd/sandbox-node/generations_test.go deleted file mode 100644 index e1795ecb2..000000000 --- a/services/agents-api/cmd/sandbox-node/generations_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "syscall" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" -) - -func TestGenerationJournalRestartIdentity(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 9, Provider: "docker"} - stateDir := t.TempDir() - directory := filepath.Join(stateDir, "generations") - if err := os.Mkdir(directory, 0700); err != nil { - t.Fatal(err) - } - journal := map[string]any{"installation_id": config.InstallationID, "generation": config.Generation, "specification_digest": config.Specification.Digest(config.Provider)} - for _, suffix := range []string{".collecting", ".dropped"} { - t.Run(suffix, func(t *testing.T) { - path := filepath.Join(directory, "9"+suffix) - if suffix == ".collecting" { - journal["native_complete"] = true - } else { - delete(journal, "native_complete") - } - write := func(value map[string]any) { - raw, _ := json.Marshal(value) - if err := os.WriteFile(path, raw, 0600); err != nil { - t.Fatal(err) - } - } - write(journal) - state, err := generationLocalState(config, stateDir) - if err != nil || state != suffix[1:] { - t.Fatal(state, err) - } - for _, field := range []string{"installation_id", "generation", "specification_digest"} { - previous := journal[field] - journal[field] = "foreign" - write(journal) - if _, err = generationLocalState(config, stateDir); err == nil { - t.Fatal("accepted mismatched", field) - } - journal[field] = previous - } - journal["native_complete"] = nil - write(journal) - if _, err = generationLocalState(config, stateDir); err == nil { - t.Fatal("accepted null journal phase") - } - if suffix == ".collecting" { - journal["native_complete"] = true - } else { - delete(journal, "native_complete") - } - write(journal) - if err = os.Chmod(path, 0644); err != nil { - t.Fatal(err) - } - if _, err = generationLocalState(config, stateDir); err == nil { - t.Fatal("accepted non-private marker") - } - if err = os.Remove(path); err != nil { - t.Fatal(err) - } - foreign := filepath.Join(directory, "foreign") - raw, _ := json.Marshal(journal) - if err = os.WriteFile(foreign, raw, 0600); err != nil { - t.Fatal(err) - } - for _, link := range []func(string, string) error{os.Symlink, os.Link} { - if err = link(foreign, path); err != nil { - t.Fatal(err) - } - if _, err = generationLocalState(config, stateDir); err == nil { - t.Fatal("accepted linked journal") - } - if err = os.Remove(path); err != nil { - t.Fatal(err) - } - } - if err = syscall.Mkfifo(path, 0600); err != nil { - t.Fatal(err) - } - if _, err = generationLocalState(config, stateDir); err == nil { - t.Fatal("accepted FIFO journal") - } - if err = os.Remove(path); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestGenerationHelperUsesOnlyTypedExit(t *testing.T) { - for _, code := range []string{"1", "65", "78"} { - err := exec.Command("sh", "-c", "exit "+code).Run() - got := generationHelperError(t.Context(), err) - if errors.Is(got, sandbox.ErrRuntimeDownloadFailed) != (code == "65") { - t.Fatal(code, got) - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if !errors.Is(generationHelperError(ctx, err), context.Canceled) { - t.Fatal("cancellation was lost") - } - } - if errors.Is(generationHelperError(t.Context(), errors.New("runtime_download_failed")), sandbox.ErrRuntimeDownloadFailed) { - t.Fatal("parsed arbitrary provider text") - } -} - -func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { - config := providerconfig.Config{InstallationID: "installation", Generation: 2, Provider: "docker"} - stateDir := t.TempDir() - directory := filepath.Join(stateDir, "generations") - if err := os.Mkdir(directory, 0700); err != nil { - t.Fatal(err) - } - path := filepath.Join(directory, "2.preparing") - for _, started := range []bool{false, true} { - raw, _ := json.Marshal(map[string]any{"installation_id": config.InstallationID, "generation": config.Generation, "specification_digest": config.Specification.Digest(config.Provider), "import_started": started, "configuration": config}) - if err := os.WriteFile(path, raw, 0600); err != nil { - t.Fatal(err) - } - journal, err := readGenerationJournal(path) - if err != nil || journal.Configuration == nil { - t.Fatal("pending-only discovery", err) - } - state, err := generationLocalState(*journal.Configuration, stateDir) - if err != nil || state != "preparing" { - t.Fatal(state, err) - } - if _, err := os.Stat(filepath.Join(directory, "2.json")); !os.IsNotExist(err) { - t.Fatal("unresolved plan published") - } - } -} diff --git a/services/agents-api/cmd/sandbox-node/main.go b/services/agents-api/cmd/sandbox-node/main.go deleted file mode 100644 index d5836a3f8..000000000 --- a/services/agents-api/cmd/sandbox-node/main.go +++ /dev/null @@ -1,152 +0,0 @@ -// sandbox-node hosts the selected local Provider and dials its owning Core. -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - providerconfig "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" -) - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := run(ctx, os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(exitCode(err)) - } -} - -// exitRejected tells the service manager not to restart the node: Core rejected -// its credential because the node was removed or retired, so no retry can succeed. -// The node units set RestartPreventExitStatus to this value (EX_CONFIG). -const exitRejected = 78 - -func exitCode(err error) int { - if errors.Is(err, node.ErrAuthentication) { - return exitRejected - } - return 1 -} - -func run(ctx context.Context, args []string) error { - if renamed := log.RenamedEnvironment(); len(renamed) > 0 { - return errors.New("OpenAgentCore renamed these settings; set the new names and remove the old ones: " + strings.Join(renamed, ", ")) - } - - if len(args) == 1 && args[0] == "protocol-version" { - fmt.Println(node.ProtocolVersion) - return nil - } - if len(args) == 0 || (args[0] != "register" && args[0] != "run") { - return errors.New("usage: oac-node register|run --config PATH --state-dir PATH") - } - flags := flag.NewFlagSet("sandbox-node "+args[0], flag.ContinueOnError) - configFile := flags.String("config", "", "absolute provider configuration file") - stateDir := flags.String("state-dir", "", "absolute private node state directory") - coreURL := flags.String("core-url", "", "Core HTTPS origin (register only)") - name := flags.String("name", "sandbox-node", "display name (register only)") - tokenFile := flags.String("enrollment-token-file", "", "private single-use enrollment token file (register only)") - if err := flags.Parse(args[1:]); err != nil { - return err - } - if flags.NArg() != 0 { - return errors.New("unexpected arguments") - } - if !filepath.IsAbs(*configFile) || !filepath.IsAbs(*stateDir) { - return errors.New("config and state-dir must be absolute paths") - } - if args[0] == "run" { - helper := filepath.Join(filepath.Dir(*configFile), "generation-preparer.pyz") - if info, err := os.Lstat(helper); err == nil { - if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { - return errors.New("generation preparer must be a private regular file") - } - return runGenerations(ctx, *configFile, *stateDir) - } else if !os.IsNotExist(err) { - return err - } - } - config, err := providerconfig.Load(*configFile) - if err != nil { - return err - } - built, closeProvider, err := providerconfig.Build(config) - if err != nil { - return err - } - defer closeProvider() - log.Ctx(ctx).Info("sandbox node configuration loaded", "config_path", *configFile) - if built.Provider == nil { - return errors.New("node configuration requires one local provider backend") - } - expected := node.Identity{SpecificationDigest: built.SpecificationDigest, DeploymentGeneration: config.Generation, InstallationID: built.InstallationID, Provider: config.Provider, BackendFingerprint: built.BackendFingerprint} - probe := func(ctx context.Context) (node.Health, error) { - err := built.Probe(ctx) - return node.Health{ProviderReady: err == nil}, err - } - if args[0] == "register" { - if !filepath.IsAbs(*tokenFile) { - return errors.New("enrollment-token-file must be absolute") - } - - fd, err := syscall.Open(*tokenFile, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) - if err != nil { - return errors.New("cannot open enrollment token") - } - file := os.NewFile(uintptr(fd), "enrollment token") - st, err := file.Stat() - if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { - file.Close() - return errors.New("enrollment token must be a private regular file (0600)") - } - raw, err := io.ReadAll(io.LimitReader(file, 4097)) - file.Close() - if err != nil { - return errors.New("cannot read enrollment token") - } - token := strings.TrimSpace(string(raw)) - if token == "" || len(token) > 4096 { - return errors.New("invalid enrollment token") - } - if _, err = node.InitIdentity(*stateDir, *coreURL, expected); err != nil { - return err - } - probeCtx, stopProbe := context.WithTimeout(ctx, 5*time.Second) - _, err = probe(probeCtx) - stopProbe() - if err != nil { - return fmt.Errorf("local provider readiness check failed (%s): %w", sandbox.NodeDiagnostic(err), err) - } - stored, err := node.Enroll(ctx, *coreURL, *stateDir, token, node.EnrollmentRequest{Name: *name}) - if err != nil { - return err - } - return json.NewEncoder(os.Stdout).Encode(node.EnrollmentResponse{MaxActive: stored.Identity.MaxActive, MaxRetained: stored.Identity.MaxRetained, SpecificationDigest: stored.Identity.SpecificationDigest, DeploymentGeneration: stored.Identity.DeploymentGeneration, NodeID: stored.Identity.NodeID, InstallationID: stored.Identity.InstallationID, Provider: stored.Identity.Provider}) - } - stored, err := node.RefreshIdentity(ctx, *stateDir) - if err != nil { - return err - } - expected.NodeID = stored.Identity.NodeID - if expected.SpecificationDigest != stored.Identity.SpecificationDigest || expected.DeploymentGeneration != stored.Identity.DeploymentGeneration || expected.InstallationID != stored.Identity.InstallationID || expected.Provider != stored.Identity.Provider || expected.BackendFingerprint != stored.Identity.BackendFingerprint { - return errors.New("provider configuration differs from retained node identity") - } - if *coreURL != "" && *coreURL != stored.CoreURL { - return errors.New("run uses the retained Core URL") - } - return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: *stateDir, Identity: stored.Identity, Credential: stored.Credential, Provider: built.Provider, Probe: probe}) -} diff --git a/services/agents-api/cmd/sandbox-node/main_test.go b/services/agents-api/cmd/sandbox-node/main_test.go deleted file mode 100644 index 5b93b471f..000000000 --- a/services/agents-api/cmd/sandbox-node/main_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package main - -import ( - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/google/uuid" -) - -// Only Core's rejection of the node credential stops the node service for good: -// exit 78, which the unit's RestartPreventExitStatus honors. Every other failure -// exits 1, so systemd restarts the node. -func TestExitCodeStopsTheNodeOnlyForARejectedCredential(t *testing.T) { - answering := func(status int) string { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })) - t.Cleanup(server.Close) - return server.URL - } - closed := httptest.NewServer(http.NotFoundHandler()) - closed.Close() - for _, test := range []struct { - name string - err error - want int - }{ - {"credential rejected", refreshIdentity(t, answering(http.StatusUnauthorized)), exitRejected}, - {"proxy forbids", refreshIdentity(t, answering(http.StatusForbidden)), 1}, - {"Core unavailable", refreshIdentity(t, answering(http.StatusServiceUnavailable)), 1}, - {"Core unreachable", refreshIdentity(t, closed.URL), 1}, - {"specification changed", errors.New("provider configuration differs from retained node identity"), 1}, - } { - if test.err == nil { - t.Fatal(test.name, "refresh succeeded") - } - if got := exitCode(test.err); got != test.want { - t.Errorf("%s: exit %d, want %d (%v)", test.name, got, test.want, test.err) - } - } -} - -// refreshIdentity runs the node's startup identity refresh against coreURL. -func refreshIdentity(t *testing.T, coreURL string) error { - t.Helper() - dir := filepath.Join(t.TempDir(), "state") - if err := os.Mkdir(dir, 0700); err != nil { - t.Fatal(err) - } - identity := node.Identity{InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64)} - if _, err := node.InitIdentity(dir, coreURL, identity); err != nil { - t.Fatal(err) - } - _, err := node.RefreshIdentity(t.Context(), dir) - return err -} - -func TestNodeRejectsRetiredLoggingSettingsBeforeStartup(t *testing.T) { - t.Setenv("PARSAR_LOG_LEVEL", "private-log") - t.Setenv("PARSAR_LOG_FORMAT", "") - err := run(t.Context(), []string{"run"}) - if err == nil || !strings.Contains(err.Error(), "PARSAR_LOG_LEVEL → OAC_LOG_LEVEL") || !strings.Contains(err.Error(), "PARSAR_LOG_FORMAT → OAC_LOG_FORMAT") || strings.Contains(err.Error(), "private-log") { - t.Fatalf("retired logging settings were ignored or exposed: %v", err) - } -} - -func TestProtocolVersionRequiresNoProviderOrIdentity(t *testing.T) { - // This is a binary capability check, not a provider readiness probe. - if err := run(t.Context(), []string{"protocol-version"}); err != nil { - t.Fatal(err) - } - if err := run(t.Context(), []string{"protocol-version", "--config", "/missing"}); err == nil { - t.Fatal("extra protocol capability arguments accepted") - } -} diff --git a/services/agents-api/cmd/server/auth_fixture_test.go b/services/agents-api/cmd/server/auth_fixture_test.go deleted file mode 100644 index 64e719903..000000000 --- a/services/agents-api/cmd/server/auth_fixture_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "context" - "encoding/hex" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding - -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - if b, ok := f[digest]; ok { - return b, nil - } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound -} -func newTestAuthenticator(keys []testAPIKey) (*api.Authenticator, error) { - resolver := fixtureKeyResolver{} - for _, k := range keys { - p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} - if err := p.Validate(); err != nil { - return nil, err - } - digest, err := hex.DecodeString(k.TokenSHA256) - if err != nil || len(digest) != 32 { - return nil, errors.New("invalid fixture digest") - } - if _, exists := resolver[k.TokenSHA256]; exists { - return nil, errors.New("duplicate fixture digest") - } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} - } - return api.NewDatabaseAuthenticator(resolver) -} diff --git a/services/agents-api/cmd/server/core_metrics.go b/services/agents-api/cmd/server/core_metrics.go deleted file mode 100644 index 56efb8bb1..000000000 --- a/services/agents-api/cmd/server/core_metrics.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "context" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/jackc/pgx/v5/pgxpool" -) - -// Release builders set this full source commit with -ldflags. -var buildRevision string -var processStartedAt = time.Now().UTC() - -type coreMetricsSource struct { - store *store.Store - pool *pgxpool.Pool - worker *execution.Worker - registry *gateway.Registry -} - -func metricPtr[T any](value T) *T { return &value } -func (s *coreMetricsSource) Live() coremetrics.Live { - stat := s.pool.Stat() - live := coremetrics.Live{Pool: coremetrics.Pool{InUse: metricPtr(int64(stat.AcquiredConns())), Idle: metricPtr(int64(stat.IdleConns())), Max: metricPtr(int64(stat.MaxConns()))}, Scheduler: coremetrics.Job{ID: "scheduler", Status: "stopped"}, ExecutionOwner: metricPtr(false), SlotsTotal: metricPtr(int64(0)), SlotsInUse: metricPtr(int64(0))} - if s.registry != nil { - live.ConnectedDaemons = metricPtr(int64(len(s.registry.Devices()))) - } - if s.worker != nil { - w := s.worker.MetricsSnapshot() - live.SlotsInUse, live.SlotsTotal, live.ExecutionOwner = w.SlotsInUse, w.SlotsTotal, w.ExecutionOwner - live.Scheduler = coremetrics.Job{ID: "scheduler", Status: w.Scheduler.Status, LastRunAt: w.Scheduler.LastRunAt, Processed: w.Scheduler.Processed, Failed: w.Scheduler.Failed} - } - return live -} -func (s *coreMetricsSource) Sample(ctx context.Context) coremetrics.Sample { - sample := coremetrics.Sample{Healthy: true, PoolInUse: metricPtr(int64(s.pool.Stat().AcquiredConns()))} - start := time.Now() - pingCtx, cancel := context.WithTimeout(ctx, time.Second) - err := s.pool.Ping(pingCtx) - cancel() - if err == nil { - sample.PingMS = metricPtr(float64(time.Since(start)) / float64(time.Millisecond)) - } else { - sample.Healthy = false - } - devices := []string{} - if s.registry != nil { - devices = s.registry.Devices() - } - counts, err := s.store.ReadCoreExecutionSnapshot(ctx, time.Now(), devices) - if err != nil { - sample.Healthy = false - } else { - sample.Queued, sample.InProgress = metricPtr(counts.QueuedTurns), metricPtr(counts.InProgressTurns) - sample.OldestQueuedSeconds = counts.OldestQueuedSeconds - if s.registry != nil { - sample.WaitingForDaemon = metricPtr(counts.WaitingForDaemon) - } - } - size, err := s.store.ReadCoreDatabaseSize(ctx) - if err != nil { - sample.Healthy = false - } else { - sample.DatabaseSize = &size - } - - return sample -} -func (s *coreMetricsSource) History(ctx context.Context, start, end time.Time, step time.Duration) (coremetrics.History, error) { - value, err := s.store.ReadCoreExecutionHistory(ctx, start, end, step) - if err != nil { - return coremetrics.History{}, err - } - result := coremetrics.History{Interrupted: value.Interrupted, QueueWaitMS: coremetrics.Latency{P50: value.QueueWaitMS.P50, P95: value.QueueWaitMS.P95}, Buckets: map[time.Time]*float64{}} - for _, bucket := range value.Buckets { - result.Buckets[bucket.Start.UTC()] = bucket.P95MS - } - return result, nil -} - -func reportCleanupResult(metrics *coremetrics.Service, job string, count int64, err error) { - if metrics == nil { - return - } - processed, failed := &count, int64(0) - if err != nil { - processed = nil - failed = 1 - } - metrics.ReportJob(job, time.Now(), processed, &failed, err) -} diff --git a/services/agents-api/cmd/server/installation.go b/services/agents-api/cmd/server/installation.go deleted file mode 100644 index 56b706933..000000000 --- a/services/agents-api/cmd/server/installation.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "errors" - "io" - "os" - "regexp" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) - -// installationFacts reports what GET /core/v1/installation serves: Core's own -// environment and build, plus the installer's settings snapshot. Core never -// acts on the snapshot; it only reports it. -func installationFacts(publicURL string) (api.Installation, error) { - var facts api.Installation - if id := os.Getenv("OAC_INSTALLATION_ID"); id != "" { - facts.InstallationID = &id - } - if publicURL != "" { - base := publicURL + "/v1" - facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &publicURL, &base, store.LoopbackOrigin(publicURL) - } - if sourceCommit.MatchString(buildRevision) { - revision := buildRevision - facts.SourceCommit = &revision - } - path := os.Getenv("OAC_SETTINGS_FILE") - if path == "" { - return facts, nil - } - f, err := os.Open(path) - if err != nil { - return facts, errors.New("cannot read OAC_SETTINGS_FILE") - } - defer f.Close() - raw, err := io.ReadAll(io.LimitReader(f, 64<<10+1)) - if err != nil { - return facts, errors.New("cannot read OAC_SETTINGS_FILE") - } - facts.Configuration, err = api.ParseInstallationConfiguration(raw) - return facts, err -} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go deleted file mode 100644 index 95bdb9092..000000000 --- a/services/agents-api/cmd/server/main.go +++ /dev/null @@ -1,357 +0,0 @@ -// Package main runs the standalone Agents API service. -// -// @title OpenAgentCore Agents API -// @version 1 -// @description Supported single-Agent execution resources from the pinned openai-python beta/agents contract. Bearer keys bind an execution principal to one project; optional OpenAI-Organization and OpenAI-Project headers must match that binding. -// @license.name Apache 2.0 -// @license.url https://www.apache.org/licenses/LICENSE-2.0.html -// @BasePath /v1 -// @schemes http https -// @securityDefinitions.apikey BearerAuth -// @in header -// @name Authorization -// @securityDefinitions.apikey DeploymentAdminAuth -// @in header -// @name Authorization -// @securityDefinitions.apikey NodeEnrollmentAuth -// @in header -// @name Authorization -// @securityDefinitions.apikey NodeAuth -// @in header -// @name Authorization -package main - -import ( - "context" - "errors" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - historystoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory/storeresolver" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - observationstoreresolver "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs/storeresolver" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/jackc/pgx/v5/pgxpool" -) - -func main() { - if err := run(); err != nil { - log.Bg().Error("oac-core startup failed", "error", err) - os.Exit(1) - } -} - -func run() error { - log.Init(log.ConfigFromEnv()) - if err := validateProcessConfiguration(); err != nil { - return err - } - public, err := publicURL() - if err != nil { - return err - } - concurrency, err := executionConcurrency() - if err != nil { - return err - } - logConfigurationSources() - databaseURL, err := databaseurl.FromEnvironment() - if err != nil { - return err - } - if databaseURL == "" { - return errors.New("OAC_DATABASE_URL is required") - } - credentialKey, err := credentialCipher() - if err != nil { - return err - } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - pool, err := pgxpool.New(ctx, databaseURL) - if err != nil { - return errors.New("invalid Agents API database configuration") - } - defer pool.Close() - ready, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - if err := pool.Ping(ready); err != nil { - return errors.New("Agents API database connection failed") - } - engine := os.Getenv("OAC_DEFAULT_HARNESS") - if engine == "" { - engine = "codex" - } - kinds, err := enabledHarnesses(engine) - if err != nil { - return err - } - oauthClient, err := oauthRefreshClient() - if err != nil { - return err - } - executionStore := store.NewWithCredentialCipherAndOAuthRefresh(pool, credentialKey, oauthClient) - executionStore.SetPublicURL(public) - installation, err := installationFacts(public) - if err != nil { - return err - } - metricsSource := &coreMetricsSource{store: executionStore, pool: pool} - metrics := coremetrics.New(processStartedAt, buildRevision, metricsSource) - auth, err := api.NewDatabaseAuthenticator(executionStore) - if err != nil { - return err - } - auditRetention, err := writeAuditRetention() - if err != nil { - return err - } - auditCleanupCtx, cancelAuditCleanup := context.WithCancel(ctx) - auditCleanupDone := make(chan struct{}) - go func() { - defer close(auditCleanupDone) - runWriteAuditCleanup(auditCleanupCtx, executionStore, auditRetention, metrics) - }() - defer func() { cancelAuditCleanup(); <-auditCleanupDone }() - var workerDone chan error - var worker *execution.Worker - managedNodes, err := configureManagedNodes(executionStore, public, func(ctx context.Context) error { - if worker == nil { - return errors.New("sandbox execution owner is unavailable") - } - return worker.CheckOwnership(ctx) - }) - if err != nil { - return err - } - defer managedNodes.close() - var managed *execution.RuntimeProvider - if managedNodes != nil { - managed = managedNodes.runtime - } - observationSources := map[string]runtimeobs.Source{} - if managedNodes != nil && managedNodes.setup != nil { - observationSources[managed.InstallationID] = managedNodes.setup - } else if managed != nil { - if source, ok := managed.Provider.(runtimeobs.Source); ok { - observationSources[managed.InstallationID] = source - } - } - observationResolver, err := observationstoreresolver.NewResolver(executionStore) - if err != nil { - return err - } - history, err := runtimeHistory(ctx, executionStore, public != "") - if err != nil { - return err - } - observationService, err := runtimeobs.NewService(observationResolver, observationSources, history.Options...) - if err != nil { - if history.Exporter != nil { - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - closeRuntimeHistory(closeCtx, history.Exporter) - } - return err - } - defer func() { - closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = observationService.Close(closeCtx) - if history.Exporter != nil { - closeRuntimeHistory(closeCtx, history.Exporter) - } - }() - cleanupCtx, cancelCleanup := context.WithCancel(ctx) - cleanupDone := make(chan struct{}) - go func() { - defer close(cleanupDone) - runHistoryCleanup(cleanupCtx, history.Prune, metrics) - }() - defer func() { cancelCleanup(); <-cleanupDone }() - options := []api.Option{api.WithCoreMetrics(metrics), api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore), api.WithRuntimeObservations(observationService)} - if managedNodes != nil { - options = append(options, api.WithSandboxManager(executionStore, managedNodes.admin)) - options = append(options, api.WithSandboxE2BDiscovery(func(ctx context.Context, input api.SandboxE2BDiscoveryInput, template string) (api.SandboxE2BDiscoveryResult, error) { - binary := os.Getenv("OAC_E2B_PROVIDER_BIN") - if binary == "" { - binary = "/opt/oac/e2b/oac-e2b-provider" - } - out, err := e2b.Discover(ctx, &e2b.ProcessCaller{}, binary, input.APIKey, input.APIURL, input.Domain, template) - if err != nil { - return api.SandboxE2BDiscoveryResult{}, err - } - return api.SandboxE2BDiscoveryResult{Templates: out.Templates, Builds: out.Builds}, nil - })) - } - var keyAdmin *api.DeploymentAuthenticator - if managedNodes != nil { - keyAdmin = managedNodes.admin - } else { - keyAdmin, err = deploymentAdminAuthenticator() - if err != nil { - return err - } - } - if err := api.ValidateCredentialSeparation(ctx, keyAdmin, executionStore); err != nil { - return err - } - options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin), api.WithWriteAudit(executionStore, keyAdmin), api.WithAdminManagement(executionStore), - api.WithInstallation(installation, executionStore.AddressBindings)) - if history.Reader != nil { - historyResolver, resolverErr := historystoreresolver.NewResolver(executionStore) - if resolverErr != nil { - return resolverErr - } - historyService, serviceErr := runtimehistory.NewService(historyResolver, history.Reader) - if serviceErr != nil { - return serviceErr - } - options = append(options, api.WithRuntimeHistory(historyService)) - } - var daemonHandler http.Handler - var registry *gateway.Registry - if public != "" { - wsURL, err := runtimeWebSocketURL(public) - if err != nil { - return err - } - daemonHandler, registry, err = runtime.NewGateway(executionStore, wsURL) - if err != nil { - return err - } - defer runtime.CloseConnections(registry) - var catalog *nativeinstaller.Catalog - directory := os.Getenv("OAC_NATIVE_INSTALLER_DIR") - if directory == "" { - if _, err := os.Stat("/opt/oac/native-installers/catalog.json"); err == nil { - directory = "/opt/oac/native-installers" - } - } - if directory != "" { - catalog, err = nativeinstaller.Load(directory, buildRevision) - if err != nil { - return err - } - } - options = append(options, api.WithEnvironmentRemoteURL(wsURL), api.WithNativeInstaller(catalog, buildRevision)) - } - options = append(options, api.WithExecutorConnections(func(ctx context.Context, environment, digest string) (bool, error) { - return runtimeenrollment.RuntimeConnected(ctx, executionStore, registry, environment, digest) - })) - if registry != nil { - dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, - ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} - - worker, err = execution.StartWorker(ctx, dispatcher) - if err != nil { - return err - } - if managedNodes != nil && managedNodes.setup != nil { - options = append(options, api.WithSandboxDeploymentSetup(worker.InitializeSandboxDeployment), api.WithSandboxDeploymentChanges(worker.UpdateSandboxDeployment, worker.StartSandboxReset, worker.CancelSandboxReset)) - } - workerDone = make(chan error, 1) - go func() { workerDone <- worker.Run(ctx) }() - defer func() { - stop() - if workerDone != nil { - <-workerDone - } - }() - options = append(options, api.WithExecution(worker), api.WithSessionArchive(worker.ArchiveManagedSession), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) - options = append(options, api.WithHarnesses(kinds), api.WithModelProviderDefaults(executionStore.DeploymentModelProvider)) - if managed != nil { - options = append(options, api.WithHostedEnvironments()) - } - } - if history.SampleInterval == 0 { - metrics.StopJob("runtime_sampler") - } - if history.SampleInterval > 0 { - if worker == nil { - return errors.New("Runtime history periodic sampling requires the execution worker") - } - sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{ - Interval: history.SampleInterval, - Report: func(result runtimeobs.SweepResult) { - sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - sampleErr := worker.CheckOwnership(sampleCtx) - if sampleErr == nil { - _, sampleErr = executionStore.SampleNodeHostHistory(sampleCtx) - } - cancel() - if !result.Complete { - sampleErr = errors.New("incomplete Runtime sampling sweep") - } - metrics.ReportJob("runtime_sampler", result.CompletedAt, metricPtr(int64(result.Observed)), metricPtr(int64(result.Failed)), sampleErr) - fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} - if result.Complete { - log.Bg().Debug("Runtime history sampling sweep complete", fields...) - } else { - log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) - } - }, - }) - if err != nil { - return err - } - samplerCtx, cancelSampler := context.WithCancel(ctx) - samplerDone := make(chan error, 1) - go func() { defer metrics.StopJob("runtime_sampler"); samplerDone <- sampler.Run(samplerCtx) }() - defer func() { - cancelSampler() - <-samplerDone - }() - } - metricsSource.worker, metricsSource.registry = worker, registry - metricsCtx, cancelMetrics := context.WithCancel(ctx) - metricsDone := make(chan struct{}) - go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() - defer func() { cancelMetrics(); <-metricsDone }() - handler, err := api.NewHandler(executionStore, auth, engine, options...) - if err != nil { - return err - } - if daemonHandler != nil { - routes := daemonRoutes{gateway: daemonHandler, - enrollment: runtimeenrollment.EnrollmentHandler(executionStore), - connection: runtimeenrollment.ConnectionHandler(executionStore, registry)} - if managedNodes != nil { - routes.nodeConnect = managedNodes.hub - } - handler = serverHandler(handler, &routes) - } - addr := serverAddress() - server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} - done := make(chan error, 1) - go func() { done <- server.ListenAndServe() }() - select { - case err := <-done: - return err - case err := <-workerDone: - workerDone = nil - stop() - shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = server.Shutdown(shutdown) - return err - case <-ctx.Done(): - shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return server.Shutdown(shutdown) - } -} diff --git a/services/agents-api/cmd/server/runtime_history.go b/services/agents-api/cmd/server/runtime_history.go deleted file mode 100644 index fb9b8d8b7..000000000 --- a/services/agents-api/cmd/server/runtime_history.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/url" - "os" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory/postgresreader" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs/otlpexporter" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "golang.org/x/net/http/httpguts" -) - -const ( - defaultRuntimeHistoryQueueCapacity = 256 - defaultRuntimeHistoryTimeoutSeconds = 2 - maxRuntimeHistoryQueueCapacity = 4096 - maxRuntimeHistoryTimeoutSeconds = 30 - minRuntimeHistorySampleIntervalSeconds = 5 - maxRuntimeHistorySampleIntervalSeconds = 300 -) - -type runtimeHistoryConfig struct { - Transport string `json:"transport,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Insecure bool `json:"insecure,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - QueueCapacity int `json:"queue_capacity,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - SampleIntervalSeconds int `json:"sample_interval_seconds,omitempty"` -} - -type runtimeHistorySetup struct { - Options []runtimeobs.ServiceOption - Exporter runtimeHistoryExporter - Reader runtimehistory.Reader - SampleInterval time.Duration - Prune func(context.Context) (int64, error) -} - -type runtimeHistoryExporter interface { - runtimeobs.Exporter - Close(context.Context) error -} - -func runtimeHistory(ctx context.Context, coreStore *store.Store, executionEnabled bool) (runtimeHistorySetup, error) { - config, err := loadRuntimeHistoryConfig() - if err != nil { - return runtimeHistorySetup{}, err - } - interval := time.Duration(config.SampleIntervalSeconds) * time.Second - mode := runtimehistory.CollectionPeriodic - if !executionEnabled { - interval = 0 - mode = runtimehistory.CollectionOnRead - } - capabilities := runtimehistory.Capabilities{ - CollectionMode: mode, SampleInterval: interval, Retention: 7 * 24 * time.Hour, - MinimumStep: max(30*time.Second, interval), MaximumRange: 24 * time.Hour, - MaximumPoints: 1000, MaximumSeries: 64, MaximumTotalPoints: 10000, - Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, - } - timeout := time.Duration(config.TimeoutSeconds) * time.Second - backend, err := postgresreader.New(coreStore, postgresreader.Config{Capabilities: capabilities, QueryTimeout: timeout}) - if err != nil { - return runtimeHistorySetup{}, err - } - options := runtimeobs.ExportOptions{QueueCapacity: config.QueueCapacity, Timeout: timeout} - setup := runtimeHistorySetup{Options: []runtimeobs.ServiceOption{runtimeobs.WithExporter(backend, options)}, Reader: backend, SampleInterval: interval, Prune: backend.Prune} - if config.Endpoint != "" { - exporter, err := otlpexporter.New(ctx, otlpexporter.Config{Endpoint: config.Endpoint, Headers: config.Headers, Insecure: config.Insecure, RequestTimeout: timeout}) - if err != nil { - return runtimeHistorySetup{}, err - } - setup.Exporter = exporter - // Independent queues keep an external Collector outage from delaying local history. - setup.Options = append(setup.Options, runtimeobs.WithExporter(exporter, options)) - } - return setup, nil -} - -func loadRuntimeHistoryConfig() (runtimeHistoryConfig, error) { - var config runtimeHistoryConfig - if file := os.Getenv("OAC_HISTORY_SETTINGS_FILE"); file != "" { - raw, err := os.ReadFile(file) - if err != nil { - return config, errors.New("cannot read OAC_HISTORY_SETTINGS_FILE") - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { - return config, errors.New("invalid Runtime history configuration") - } - } - if err := validateRuntimeHistoryConfig(config); err != nil { - return config, err - } - if config.QueueCapacity == 0 { - config.QueueCapacity = defaultRuntimeHistoryQueueCapacity - } - if config.TimeoutSeconds == 0 { - config.TimeoutSeconds = defaultRuntimeHistoryTimeoutSeconds - } - if config.SampleIntervalSeconds == 0 { - config.SampleIntervalSeconds = 30 - } - return config, nil -} - -func closeRuntimeHistory(ctx context.Context, exporter runtimeHistoryExporter) { - defer func() { _ = recover() }() - _ = exporter.Close(ctx) -} - -// Retention also runs without active Runtimes. Each bounded pass has its own deadline. -func runHistoryCleanup(ctx context.Context, prune func(context.Context) (int64, error), metrics *coremetrics.Service) { - if metrics != nil { - defer metrics.StopJob("history_cleanup") - } - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - pruneCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - count, err := prune(pruneCtx) - cancel() - reportCleanupResult(metrics, "history_cleanup", count, err) - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func validateRuntimeHistoryConfig(config runtimeHistoryConfig) error { - if config.QueueCapacity < 0 || config.QueueCapacity > maxRuntimeHistoryQueueCapacity { - return errors.New("Runtime history queue_capacity is out of range") - } - if config.TimeoutSeconds < 0 || config.TimeoutSeconds > maxRuntimeHistoryTimeoutSeconds { - return errors.New("Runtime history timeout_seconds is out of range") - } - if config.SampleIntervalSeconds != 0 && (config.SampleIntervalSeconds < minRuntimeHistorySampleIntervalSeconds || config.SampleIntervalSeconds > maxRuntimeHistorySampleIntervalSeconds) { - return errors.New("Runtime history sample_interval_seconds is out of range") - } - if config.Endpoint == "" { - if config.Transport != "" || config.Insecure || len(config.Headers) != 0 { - return errors.New("Runtime history export options require an endpoint") - } - return nil - } - if config.Transport != "otlp_http" { - return errors.New("Runtime history transport must be otlp_http") - } - endpoint, err := url.Parse(config.Endpoint) - if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.RawPath != "" || endpoint.Path == "" || endpoint.String() != config.Endpoint { - return errors.New("Runtime history endpoint must be a canonical absolute OTLP metrics URL") - } - switch endpoint.Scheme { - case "https": - if config.Insecure { - return errors.New("Runtime history insecure transport requires an http endpoint") - } - case "http": - if !config.Insecure { - return errors.New("Runtime history http endpoint requires insecure=true") - } - default: - return errors.New("Runtime history endpoint scheme must be https or explicit insecure http") - } - for key, value := range config.Headers { - lower := strings.ToLower(key) - if !httpguts.ValidHeaderFieldName(key) || !httpguts.ValidHeaderFieldValue(value) || lower == "host" || lower == "content-length" || lower == "content-type" || lower == "content-encoding" { - return errors.New("Runtime history headers contain an invalid or reserved entry") - } - } - return nil -} diff --git a/services/agents-api/cmd/server/runtime_history_test.go b/services/agents-api/cmd/server/runtime_history_test.go deleted file mode 100644 index 52cba81ad..000000000 --- a/services/agents-api/cmd/server/runtime_history_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -type panicHistoryExporter struct{} - -func (panicHistoryExporter) Export(context.Context, runtimeobs.ExportRecord) error { return nil } -func (panicHistoryExporter) Close(context.Context) error { panic("close") } - -func TestRuntimeHistoryUsesCoreDatabaseByDefault(t *testing.T) { - t.Setenv("OAC_HISTORY_SETTINGS_FILE", "") - for _, enabled := range []bool{true, false} { - setup, err := runtimeHistory(t.Context(), store.New(nil), enabled) - if err != nil { - t.Fatal(err) - } - if len(setup.Options) != 1 || setup.Exporter != nil || setup.Reader == nil || setup.Prune == nil { - t.Fatal("default history requires extra deployment") - } - capabilities := setup.Reader.Capabilities() - if capabilities.Durable() != enabled || capabilities.Retention != 7*24*time.Hour { - t.Fatalf("incorrect default capabilities: %+v", capabilities) - } - if enabled && setup.SampleInterval != 30*time.Second { - t.Fatal("default cadence missing") - } - if !enabled && setup.SampleInterval != 0 { - t.Fatal("sampler requires an execution owner") - } - } -} - -func TestRuntimeHistoryOptionalExportAndSamplingConfiguration(t *testing.T) { - file := filepath.Join(t.TempDir(), "history.json") - if err := os.WriteFile(file, []byte(`{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Authorization":"Bearer private"},"sample_interval_seconds":60}`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) - setup, err := runtimeHistory(t.Context(), store.New(nil), true) - if err != nil { - t.Fatal(err) - } - defer closeRuntimeHistory(t.Context(), setup.Exporter) - if len(setup.Options) != 2 || setup.SampleInterval != time.Minute || setup.Reader.Capabilities().MinimumStep != time.Minute { - t.Fatal("export and local history are not independent") - } -} - -func TestRuntimeHistoryConfigFailsClosedWithoutLeakingSecrets(t *testing.T) { - tests := []struct { - name string - config string - }{ - {name: "unknown field", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","secret":"must-not-leak"}`}, - {name: "implicit insecure", config: `{"transport":"otlp_http","endpoint":"http://collector.example.test/v1/metrics"}`}, - {name: "userinfo", config: `{"transport":"otlp_http","endpoint":"https://user:must-not-leak@collector.example.test/v1/metrics"}`}, - {name: "header newline", config: "{\"transport\":\"otlp_http\",\"endpoint\":\"https://collector.example.test/v1/metrics\",\"headers\":{\"Authorization\":\"Bearer must-not-leak\\n\"}}"}, - {name: "reserved header", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Host":"must-not-leak"}}`}, - {name: "oversized queue", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","queue_capacity":4097}`}, - {name: "oversized timeout", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","timeout_seconds":31}`}, - {name: "too frequent sampling", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":4}`}, - {name: "oversized sampling interval", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":301}`}, - {name: "removed backend", config: `{"clickhouse":{"password":"must-not-leak"}}`}, - {name: "transport without endpoint", config: `{"transport":"otlp_http"}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - file := filepath.Join(t.TempDir(), "runtime-history.json") - if err := os.WriteFile(file, []byte(test.config), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) - _, err := loadRuntimeHistoryConfig() - if err == nil { - t.Fatal("unsafe history configuration accepted") - } - if strings.Contains(err.Error(), "must-not-leak") { - t.Fatalf("history error leaked config content: %v", err) - } - }) - } -} - -func TestRuntimeHistoryAllowsExplicitLocalHTTPCollector(t *testing.T) { - config := runtimeHistoryConfig{ - Transport: "otlp_http", Endpoint: "http://127.0.0.1:4318/v1/metrics", Insecure: true, - Headers: map[string]string{"X-Scope-OrgID": "operator-history"}, - } - if err := validateRuntimeHistoryConfig(config); err != nil { - t.Fatal(err) - } -} - -func TestRuntimeHistoryClosePanicIsIsolated(t *testing.T) { - closeRuntimeHistory(t.Context(), panicHistoryExporter{}) -} diff --git a/services/agents-api/cmd/server/write_audit.go b/services/agents-api/cmd/server/write_audit.go deleted file mode 100644 index ed604fca8..000000000 --- a/services/agents-api/cmd/server/write_audit.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - "errors" - "os" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" -) - -type writeAuditPruner interface { - DeleteExpiredWriteOperations(context.Context, time.Time, int) (int64, error) -} - -func writeAuditRetention() (time.Duration, error) { - value := os.Getenv("OAC_WRITE_AUDIT_RETENTION") - if value == "" { - return 90 * 24 * time.Hour, nil - } - duration, err := time.ParseDuration(value) - if err != nil || duration < time.Hour { - return 0, errors.New("OAC_WRITE_AUDIT_RETENTION must be a duration of at least 1h") - } - return duration, nil -} - -func runWriteAuditCleanup(ctx context.Context, s writeAuditPruner, retention time.Duration, metrics *coremetrics.Service) { - if metrics != nil { - defer metrics.StopJob("audit_cleanup") - } - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - pruneCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - count, err := s.DeleteExpiredWriteOperations(pruneCtx, time.Now().Add(-retention), 1000) - cancel() - reportCleanupResult(metrics, "audit_cleanup", count, err) - if err != nil && ctx.Err() == nil { - log.Ctx(ctx).Warn("Write audit retention cleanup failed") - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} diff --git a/services/agents-api/cmd/specification-contract/main.go b/services/agents-api/cmd/specification-contract/main.go deleted file mode 100644 index 67f445eac..000000000 --- a/services/agents-api/cmd/specification-contract/main.go +++ /dev/null @@ -1,35 +0,0 @@ -// specification-contract maintains the generated node installer projection. -package main - -import ( - "flag" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" - "os" - "strings" -) - -func main() { - write := flag.Bool("write", false, "update deploy/install/node_spec.py from the repository root") - flag.Parse() - projection := providers.PythonDeploymentContract() - if !*write { - fmt.Println(projection) - return - } - path := "deploy/install/node_spec.py" - raw, err := os.ReadFile(path) - if err != nil { - panic(err) - } - source := string(raw) - start := strings.Index(source, "# BEGIN GENERATED DEPLOYMENT CONTRACT") - end := strings.Index(source, "# END GENERATED DEPLOYMENT CONTRACT") - if start < 0 || end < start { - panic("missing generated contract markers") - } - end += len("# END GENERATED DEPLOYMENT CONTRACT") - if err = os.WriteFile(path, []byte(source[:start]+projection+source[end:]), 0644); err != nil { - panic(err) - } -} diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md deleted file mode 100644 index 09d5bdc4f..000000000 --- a/services/agents-api/deploy/claude/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Claude Code on the dedicated Runtime - -Core is independently deployed. Each managed Session receives one Docker Runtime -containing the daemon, pinned Claude Agent SDK/native harness and local workspace. -The SDK owns the model/tool loop. Public clients use the same Agents API contract. - -## Build and configure - -The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) -builds the image. The bundle pins SDK `0.3.269` and native Claude Code `2.1.269`. The Dockerfile pins -Node's Linux amd64 manifest. Keep the exported SDK bundle immutable. Configure -Core's database-owned managed deployment with the resulting immutable Runtime -image. Existing Docker outer security settings are unchanged; the daemon and -native adapter do not add an inner sandbox. Tools run as UID/GID 1000 and can -access files available to that user. - -Install required system dependencies while building the image/template. Runtime -has no automatic apt installation, sudo or elevated daemon permissions; -`system_packages` is unsupported and missing dependencies cause operation failure. -npm/Python package and setup commands run directly with the existing user's -permissions. The packaged image no longer needs bubblewrap or socat for an inner -sandbox. For native self-hosting and platform limits, see the -[self-hosted guide](../../../../docs/getting-started/self-hosted.md#platforms). - -Set `OAC_DEFAULT_HARNESS=claude_sdk`. Configure the deployment default model provider -with the Core key, in Web or through Core's API: - -```sh -curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/claude_sdk/model-provider \ - -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ - -d '{"protocol":"anthropic","base_url":"https://api.anthropic.com","api_key":"REPLACE_WITH_OPERATOR_SECRET"}' -``` - -A Session may instead supply its own `x_agents_core.model_provider`. Use the -endpoint and model supported by your actual provider. Do not bake keys into the -image. Core freezes the bundle in the Session's encrypted snapshot and delivers it -to the adapter; it is not a public Agent field. Ordinary environment projection -does not isolate locally stored credentials from tools running as the same user. -Follow the existing Core setup for independent PostgreSQL credentials, migrations, -API authentication and managed Runtime enrollment. Parsar is not a dependency. - -The supported hosted profile accepts text execution with medium verbosity and -native Bash/Read/Edit and declared public functions with text results. Files and Artifacts use the shared public interfaces. -Check the current qualified engine profile for MCP, subagents and structured -output combinations. Historical hosted-profile acceptance does not qualify native -self-hosted platforms or every feature combination. The existing -`environment:none` function/MCP profile is separate. This is not complete upstream -protocol compatibility. - -## Runtime and adapter rules - -Native self-hosted installations use the same bundle and daemon protocol. -The shared local binding selects the actual workspace. SDK history, home and -scratch remain under `OAC_RUNTIME_HOME/runtime/claude-sdk` for session ownership, -without restricting tools. Authentication remains under `OAC_RUNTIME_HOME/daemon`. -The daemon requires neither nested sandbox privileges nor host security changes. - -The `local_runtime_v2` capability identifies the current workspace and direct MCP -launcher contract. Reject earlier workspace bundles; matching SDK versions alone -do not establish adapter compatibility. -The adapter advertises `local_runtime_v2` after checking the installed bridge and -native binary. Windows additionally needs Git Bash for its Bash tool. Registration -combines that check with the local binding; Core consumes the same readiness -contract on every platform. The profile supports Bash/Read/Edit, preparation, -shared Files/Artifacts, cancellation and history continuation. Other capabilities -remain subject to their actual engine qualification; bypass execution does not -silently qualify a new MCP or function combination. - -Recovery uses the SDK's history APIs. An explicitly supplied native identity must -exist. If Core requires existing history without having recorded an identity, the -adapter accepts only one nonempty native history for the exact bound cwd. Missing, -foreign, ambiguous or metadata-only history rejects before model input. The Runtime -volume and shared Environment/Session binding establish ownership; this lookup -cannot select another Session's home or infer ownership from a model response. - -Claude hosted functions compose the existing SDK function bridge with the common -workspace execution profile. Only declared function tools and the verified native tool -inventory are available. The bundle advertises this combination separately from -basic workspace execution; function preparations require that verified combination. -Service-origin hosted MCP remains unqualified; Environment Plugin declarations -use the separately qualified initialization path above. Function callbacks do not change file, -credential, history, subagent or network authority. - -## Adding another native engine - -Follow [Add a native Harness](../../../../contracts/agents-api/harness-onboarding.md). -The Claude adapter is one of its [native references](../../../../contracts/agents-api/harness-onboarding.md#native-references): -it implements the shared `agent.ExecutorFactory`, `Executor` and `Turn` -interfaces and registers them in -[`cli/claude_sdk.go`](../../../../apps/parsar-daemon/internal/cli/claude_sdk.go). -Acceptance requirements are in -[Harness integration](../../../../contracts/agents-api/harnesses.md#acceptance-checklist). diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md deleted file mode 100644 index 78d97b834..000000000 --- a/services/agents-api/deploy/e2b/README.md +++ /dev/null @@ -1,193 +0,0 @@ -# E2B Runtime deployment - -E2B supports two separate ownership choices. Core-managed `openai_hosted` uses -the deployment-wide E2B Provider. Application-managed `self_hosted` uses the -startup example below; in that path the application creates, renews and destroys -its own sandbox, and Core receives neither the E2B account key nor an allocation -request. The sandbox runs the existing V1 daemon, selected native harness, tools -and workspace together. Codex, Claude Code and MiniMax Code use the same startup -contract and their respective qualified Runtime images. - -This deployment uses OpenAgentCore daemon enrollment, not Codex `exec-server` or Noise. -Public execution and Files/Artifacts continue through Core and the daemon; -E2B commands/files are used only for deployment, initialization and inspection. -A public Session deletion does not destroy an application-owned VM; managed -compute cleanup remains Core's responsibility. - -## Core-managed hosted deployment - -Select E2B in Core's hosted deployment setup and supply the account key and an -immutable `templateID:build_UUID`. One deployment uses one managed Provider; -E2B needs no physical node enrollment. Changing backend requires explicit reset -and confirmed cleanup of every owned allocation, pending creation and snapshot. -Do not infer execution readiness from saved configuration or running compute. - -The pure-Go adapter invokes the packaged official Python SDK helper. The Core -image and native installation include its runtime dependencies; configuring E2B -does not require installing Python or pip on the server. The account key is -write-only server configuration, passed to the helper over stdin. It never enters -a template, command argument, inherited environment, receipt or public response. - -Keep the helper's private receipt directory on durable storage, owned by the Core -service user with mode 0700. Manual deployments using the default non-root Core -image must give its service UID ownership of that directory. SDK connection -credentials and one-shot allocation claims are stored there; they are not Core -execution state. Preserve them across upgrades and failures. An empty cloud -lookup cannot settle an unknown Create, and inspection never creates, resumes -or reboots compute. Initialization uncertainty requires reclaiming the original -allocation rather than replaying startup. See the [helper contract](../../tools/e2b-provider/README.md). - -Rebuild old templates with this directory's `build-template.py` before managed -use: it includes protected `init.py` and `managed_init.py`. The latter consumes -Core's existing managed daemon auth profile, while application-managed startup -continues to use the executor credential flow below. Both reuse the same daemon, -native harnesses and colocated workspace. A qualified combined Runtime image can -support several harnesses; provider selection is independent of harness choice. - -## Build the packaged Runtime - -Use Python 3.12+, Docker and a qualified Linux amd64 Runtime image containing the -environment-aware daemon `connect` command. Keep keys and build outputs outside -the checkout, in private directories. Install the pinned SDK from this directory: - -```sh -python -m venv "$HOME/.oac/build/e2b-sdk" -"$HOME/.oac/build/e2b-sdk/bin/pip" install -r services/agents-api/deploy/e2b/requirements.txt -"$HOME/.oac/build/e2b-sdk/bin/python" services/agents-api/deploy/e2b/build-template.py \ - --image sha256:QUALIFIED_RUNTIME_IMAGE_DIGEST \ - --name your-runtime-build \ - --api-key-file "$HOME/.oac/secrets/e2b.key" \ - --output "$HOME/.oac/build/e2b-template.json" -``` - -The builder preserves the existing image's binaries, native configuration and -private workspace layout. Its `template` output is an immutable -`templateID:build_UUID`; use that exact value. The build must qualify every harness it advertises; a combined Runtime image -can include several harnesses. No E2B account key, executor key or model credential belongs in a -build, template environment, metadata, command argument or log. The builder gives -traversable modes only to the public archive ancestors it creates (`usr`, -`usr/local`, `etc`). Runtime file and directory modes, private build contexts, -key inputs and the umask of its output stay unchanged, including under umask 077. - -System dependencies must be installed when building the template. The builder may -use root during image construction, but the running daemon remains UID/GID 1000 -with no automatic apt, sudo or privilege escalation. Runtime `system_packages` -is unsupported; missing dependencies fail their consuming operation. The template -no longer installs bubblewrap or socat for inner sandboxing. - -## Application-managed: start an existing self-hosted Environment - -Create a public `self_hosted` Environment through Core and retain its ID and exact -returned `remote_url`. Obtain an authorized connect-only executor key scoped to -that Environment (or its owning principal) through the operator credential flow. -The key JSON is `{"key_id":"UUID","executor_token":"SECRET"}` with an optional -`environment_id` restriction. Store both this JSON and the separate E2B API key -in private files with mode `0600`. - -The current packaged profile uses public `/workspace`, backed by -`/environment/workspace`. The remote endpoint must be reachable from the VM; -use the returned `wss://.../api/v1/agent-daemon/ws` unchanged. The native profile -comes from the image, and model credentials arrive through authenticated Core -execution. Managed startup uses the separate [Runtime bootstrap contract](../../../../docs/runtime-bootstrap.md). - -Generate and retain an application launch UUID once. `launch.py` is a thin SDK -example, not a service or a replacement lifecycle owner: - -```sh -"$HOME/.oac/build/e2b-sdk/bin/python" services/agents-api/deploy/e2b/launch.py \ - --template 'TEMPLATE_ID:BUILD_UUID' \ - --remote-url 'RETURNED_REMOTE_URL' \ - --environment-id 'RETURNED_ENVIRONMENT_UUID' \ - --launch-id 'YOUR_APPLICATION_LAUNCH_UUID' \ - --executor-key-file "$HOME/.oac/secrets/executor-key.json" \ - --api-key-file "$HOME/.oac/secrets/e2b.key" \ - --record "$HOME/.oac/runtimes/YOUR_APPLICATION_LAUNCH_UUID.json" \ - --timeout 7200 -``` - -Choose a lease supported by your E2B account and renew it before expiry. The -example creates an exclusive private launch record before Create, stores the -returned sandbox ID before startup, and sets `on_timeout=kill` with auto-resume -disabled. Metadata contains only the application launch ID and Environment ID. -It never repeats Create/start, replaces a sandbox or deletes failure evidence. -Reusing the record path rejects before any cloud call. Do not bypass that guard -by supplying a new path after an uncertain result. - -## Inspect, renew and destroy - -The application remains responsible for the lease and cleanup, including after -Session deletion or daemon failure. These SDK calls use the retained exact ID -and do not connect to, resume or recreate a sandbox: - -```python -import json -from pathlib import Path -from e2b import Sandbox - -record = json.loads(Path('/private/launch.json').read_text()) -api_key = Path('/private/e2b.key').read_text().strip() -sandbox_id = record['sandbox_id'] -info = Sandbox.get_info(sandbox_id, api_key=api_key) -assert info.metadata['oac_launch_id'] == record['launch_id'] -assert info.metadata['oac_environment_id'] == record['environment_id'] -Sandbox.set_timeout(sandbox_id, 7200, api_key=api_key) # When renewing the live VM. -# When the application is finished, or explicitly abandons this allocation: -Sandbox.kill(sandbox_id, api_key=api_key) -``` - -If Create's response was lost before its ID was saved, discover candidates using -`Sandbox.list(query=SandboxQuery(metadata={'oac_launch_id': launch_id}), -api_key=api_key)`, importing `SandboxQuery` from `e2b`. Consume pages while -`paginator.has_next` via `paginator.next_items()`. Verify both metadata fields -against the private record, retain every matching provider ID, and explicitly -inspect or destroy those allocations. An empty lookup is not permission to retry -an uncertain Create. Do not select an arbitrary candidate or rotate its identity. - -An uncertain startup result requires inspection of the same VM or explicit -cleanup. Root-only `/root/.oac/e2b/launch.json` records the startup claim; -`ready.json` records only successful process handoff (`daemon_started`), even if -the daemon subsequently exits. Neither proves enrollment, native readiness or a -successful Turn. Check public Core Environment status and the private daemon log -at `/home/runtime/.oac/daemon/default/daemon.log`. The daemon's separate -`/home/runtime/.oac/daemon/environment.json` records the verified -Environment/Session binding. Keep these records and native history on failure. -Do not rerun `init.py`; it refuses any claimed attempt, including interrupted ones. -The SDK's `Sandbox.connect` can resume paused sandboxes, so it is not used as an -automatic recovery/inspection step here. VM expiry destroys volatile history; -never claim a replacement VM recovered the original Session. - -## Startup and security boundary - -The protected image initializer uses the image's explicit environment, restores -E2B-finalized executable/service permissions, locks the unused privileged `user` -account, and binds `/workspace`. It writes the executor key to a mode-`0600` file -inside the protected daemon directory, then starts the existing daemon as UID -1000 with that file path. The input is removed before startup. No bearer enters -the daemon's argv or inherited environment. Enrollment and immutable local binding -remain daemon responsibilities; startup does not invent device/Session IDs. - -E2B clears `/run` at boot, so startup records live under `/root/.oac/e2b`. -The E2B VM is the outer isolation boundary. The daemon and native harness add no -inner filesystem, permission or network sandbox; tools have UID 1000's access to -Runtime state. Verify the actual outer boundary, process cleanup and native -execution for each template. Prior private-file denial results describe the old -inner sandbox and do not qualify the current behavior. -The one-shot receipt cannot be reused to replace the daemon or overwrite history. - -## Verification scope - -Run the focused local tests with the pinned SDK environment: - -```sh -python -m unittest discover -s services/agents-api/deploy/e2b -p '*_test.py' -v -``` - -These are controlled startup-contract tests: input binding, protected key output, -unchanged URL, no secret in argv/environment/record, one-shot claim, and retained -provider ID on unknown outcomes. They do not create billable resources or qualify -E2B security, enrollment or model execution. The [qualification record](../../../../contracts/agents-api/user-managed-runtime-v1.md) -records historical three-harness deployment results and their verification limits, -including shared Core credential lifecycle checks and explicit application-owned -cleanup. `tests/official_user_runtime.py` supplies the shared -public execution checks. The former Core-managed `official_e2b_v1.py` fixture is -retired; its original source and evidence remain in Git history. diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md deleted file mode 100644 index 185b68d48..000000000 --- a/services/agents-api/deploy/mcode/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# MiniMax Code Runtime - -MiniMax Code uses the same Core/Runtime execution contract as the other engines. -The text profile supports `environment:none`; the dedicated Docker profile adds -workspace execution and the shared Files/Artifacts path. The historical -workspace qualification (evidence under `~/.parsar/remediation/20260919/mcode-workspace/`) -does not describe the current bypass profile. -Public MCP/functions, image input and native Subagent execution remain outside -this batch. - -## Pinned prerequisite - -Use the official [`@minimax-ai/code`](https://github.com/MiniMax-AI/minimax-code) -package version **0.4.12**, with Node.js 22.x and its native SQLite dependency. The Docker image pins Node.js -22.23.1; the text fixture used 22.22.0. -The inspected upstream source is `33b259bbbeb1c16433390869938191d09bdb0680`. -Install outside the checkout, under a private operator directory in `~/.oac/`. -Check the native install succeeds and `mcode --version` reports exactly 0.4.12. -This profile runs on a trusted execution host. - -Set `OAC_RUNTIME_MCODE_BIN` to that absolute executable and `OAC_RUNTIME_MCODE_AGENTS_API=1` -for the daemon. The opt-in only advertises the profile for the qualified version. -Use the existing authenticated daemon connection and operator device enrollment; -native self-hosted installation uses the same Runtime protocol. See the -[self-hosted guide](../../../../docs/getting-started/self-hosted.md#platforms); MiniMax on Windows remains -unsupported. -Set `OAC_DEFAULT_HARNESS=mcode` in the independent Core deployment. Existing Sessions -retain their engine. Do not expose a new public harness selector. - -## Docker workspace - -The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) -builds the companion from the pinned native source and the Runtime image. - -Configure Core's existing managed Docker provider with the immutable image ID, -`deploy/codex/seccomp.json` and `nested_sandbox: true`. Core, database ownership, -enrollment and the public protocol remain shared. The image supplies the private -`OAC_RUNTIME_MCODE_WORKSPACE=managed` and companion paths; caller Agent options cannot -change them. Public Files and Artifacts use the common bound workspace helpers. - -The native process, ACP Session and six original native tools use the declared -Environment workspace (`/workspace` in the Docker image). The tools connect -through one trusted MCP bridge with the daemon user's ordinary permissions and -no inner sandbox. MCP is internal transport here; its presence alone does not -enable caller-supplied public MCP servers. Native project discovery uses that -same workspace; adapter-owned configuration and history remain in the private -Session data directory. Exact native-ID continuation remains required; recovery -without a recorded ID fails closed. Native Bash observations become public -`command_execution` items after command arguments arrive. Their text output and -status are retained; absent native exit code/duration stay unknown. The private -MCP server and file/skill/task utilities are not invented public MCP or function -calls. - -## Provider configuration - -Supply an Anthropic-compatible bundle with the model's context and output limits, -either per Session as `x_agents_core.model_provider` or as the deployment default, -set with the Core key in Web or through Core's API: - -```sh -curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/mcode/model-provider \ - -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ - -d '{"protocol":"anthropic","base_url":"https://api.moonshot.cn/anthropic","api_key":"","context_window":64000,"max_output_tokens":4096}' -``` - -Core maps the bundle to the native custom provider for the Session's exact -`agent.model`, with these limits. The adapter selects it explicitly; it does not -use a native account fallback. -Use the real provider endpoint for the selected credential. Test proxies may -forward requests unchanged and capture tool names/status, but must not synthesize -model responses or log keys. - -MiniMax M2 Chat Completions may return inline `` content. The pinned native -Harness does not expose the provider's `reasoning_split` option; the adapter does -not guess which response text to remove. Anthropic Messages supplies distinct -thinking blocks. File read/write utilities also have no qualified public Item -mapping; only actual Bash calls become `command_execution`. - -## Execution boundaries - -Each API Session uses a separate native state directory. Workspace execution -uses the declared Environment directory; text-only execution uses a private cwd. -The execution child inherits only process and model-network essentials; it does -not inherit Core/daemon tokens or arbitrary Node startup configuration. The -adapter owns its native config, instructions and home and disables external -skills, delegated work, web search, builtin file/shell tools, browser tools, -mcode-tools and native goals. The workspace profile uses its tool bridge without -sandbox isolation. Tools can read any local state available to the launching -user. The outer Environment owns managed isolation; daemon network modes do not -add another network boundary. - -Provide system dependencies at image/template build time or on the self-hosted -machine before execution. Runtime does not run apt or sudo or elevate daemon -permissions. `system_packages` is unsupported; missing dependencies fail the -operation requiring them. npm/Python packages and setup retain direct execution -with the user's existing permissions. - -Workspace Skills use the frozen installation's exact names and native `skill` -loader. Session-local links resolve to the installed package directories; -external discovery stays disabled. The text-only profile has no selected Skills. -Task utilities such as `task_query`, `task_output` and `task_stop` cannot create -work and enforce native Session ownership. Auxiliary native title requests are -internal bookkeeping. Do not equate these with public function or workspace tools. - -ACP `mcode/session/steer` acknowledges acceptance for the active native Turn, -separately from the transport write. It is not a promise that the model consumed -that input before cancellation. Unknown outcomes are never automatically replayed. -Cancellation waits for process-group exit and output settlement. Public slash -text remains a model message instead of invoking native ACP operator commands. - -Cold continuation requires the exact persisted native Session ID and matching -workspace cwd (or private cwd for text-only execution). Missing/foreign history -fails; recovery by guessing an ID from native session listings is not qualified. -Public usage breakdown is unavailable because native ACP context occupancy and -cumulative cost are not per-Turn usage. - -### Adapter rules - -MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for -`environment:none` text execution. It reuses the shared lifecycle without public -workspace, functions or MCP. Enabled Subagents use the separately qualified -common observation path below. Native configuration disables -file/shell authority and external -capability discovery; the child receives a private Session home and a restricted -environment. Active-input application requires a native ACP receipt. Normal -Turns retain one ACP connection and native Session. Cancellation requires native -root and child completion evidence before reuse. Without a qualified root-state -reader, the ACP cancellation response is insufficient: stop the invalid native -process and wait for its exit before settling the Turn as non-reusable. Common -Runtime recovery then loads the exact owned native history in a new Executor. Do not infer history IDs or qualify hosted execution from this text -profile. See [Acceptance](#acceptance). - -MiniMax companion readiness uses private protocol 2; old companions are rejected -even when the upstream version matches. Cancellation retires the executor and -settles its native workers and detached Bash groups before acknowledgement; -later work recovers the same native history in a new owner without replay. - -The MiniMax workspace profile builds one CLI from the fixed upstream source and -lockfile through the existing companion packaging path, and connects native -workspace tools through its standard MCP client. The process and native Session -share the declared workspace directory, including for cold continuation. A -trusted adapter-owned bridge runs the original six tool implementations with the -launching user's ordinary permissions. It adds no inner sandbox on any platform. -Keep native history in the Session data directory and native execution and -Files/Artifacts bound to the same Environment workspace. Core and shared file -helpers remain engine neutral. This internal MCP transport does not admit public -MCP configuration. Record the upstream revision, native admission patch hashes -and worker-source provenance. The bounded patch checks the shared -descendant-task limit inside the existing native SQLite admission transaction -before start, without another scheduler. ACP initialization must acknowledge the -applied limit before input. The native tool catalog selects the same admitted -workspace tools for every child, not only the root's configured profile; this is -tool selection, not filesystem isolation. Only the Session's authorized internal -workspace MCP entry crosses the native child selector; this does not grant -external MCP access. Initialization must acknowledge the admitted tool inventory -before input as well. Subagent reads use the Session's native database; the -daemon does not protect it from other tools running as the same user. -Multi-agent workspace execution installs only the existing authorized workspace -MCP entry in that private native configuration so children inherit the same -tools; public MCP and Environment-origin MCP combinations remain separately -qualified. Complete the hosted [acceptance -checklist](../../../../contracts/agents-api/harnesses.md#acceptance-checklist) -before enabling hosted execution. The standalone companion uses its own npm -lock; `make check` runs its lifecycle tests and script checks, while its -exact-source Linux build and native qualification for the actual supported -platform and outer deployment are required when the companion changes. -Historical tests of both inner network policies do not qualify the current -bypass implementation. - -## Acceptance - -Recorded results below are historical evidence for their exact binaries and -profiles, not qualification of the current native platforms or bypass behavior. - -The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, -raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. -Provide private `OAC_TEST_MCODE_REAL_OPTIONS` (the provider object above plus the -`model` string), `OAC_RUNTIME_MCODE_BIN`, `OAC_TEST_NATIVE_DAEMON_BIN`, -`OAC_TEST_NATIVE_PROOF_DIR`, `OAC_TEST_OFFICIAL_SDK_PYTHON` and -`OAC_TEST_DATABASE_URL`, then run: - -```sh -go test ./services/agents-api/internal/store \ - -run '^TestNativeMCodePublicExecution$' -count=1 -v -timeout=15m -``` - -It verifies real text execution, same-Turn steering and durable input receipt, -daemon cold restart with the same native ID, ordinary slash text, foreign-tenant -rejection, cancellation and subsequent continuation. Run independently with real -Kimi and MiniMax options. This fixture creates only operator device credentials -privately; all tested Sessions and inputs enter through public HTTP. - -`TestNativeMCodeHistoryIsolation` additionally takes -`OAC_TEST_MCODE_FOREIGN_NATIVE_ID` from a successful public run and verifies rejection -in another private native home, plus rejection of a nonexistent history ID. Run it -in the daemon's `internal/agent/mcode` package with the same private native/provider -options. It must fail before model input, without a replacement native Session. - -On 2026-09-19, the public fixture passed independently with real Kimi K3 and -MiniMax M2.7 APIs. Both exercised execution, native steering receipts, daemon -restart, history continuation, tenant rejection and cancel/continue. Native -missing/foreign history rejection passed separately. Credential scans found the -provider key only in each private mode-0600 native config, not in logs, public -results or native history. Product ACP regression uses the same 0.4.12 package -and covers new/resume, model/instruction refresh, Skill discovery and MCP using -its existing synthetic provider fixture. - -That text acceptance used an in-process Core HTTP server and a separate real -daemon. The subsequent Docker workspace qualification -(`~/.parsar/remediation/20260919/mcode-workspace/`) used independently built -Core binaries, its own database and managed Runtime. -It covers public Files/Artifacts, cancellation, reconnect, exact-history recovery -and isolation with real Kimi and MiniMax APIs. Read its recorded Kimi continuation -limitation: new model-issued commands are not automatic recovery replay. Neither -acceptance establishes complete official protocol compatibility. diff --git a/services/agents-api/internal/api/admin_history.go b/services/agents-api/internal/api/admin_history.go deleted file mode 100644 index 30fd05457..000000000 --- a/services/agents-api/internal/api/admin_history.go +++ /dev/null @@ -1,69 +0,0 @@ -package api - -import ( - "net/http" - "net/url" - "strconv" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// @Summary Query committed administrator mutations -// @Description Core key only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id query string false "Target Project ID" -// @Param resource_type query string false "Resource type" -// @Param resource_id query string false "Resource ID" -// @Param action query string false "Mutation action" -// @Param created_after query string false "Inclusive RFC3339 timestamp" -// @Param created_before query string false "Exclusive RFC3339 timestamp" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(50) -// @Param after query string false "Opaque next_cursor from the preceding page" -// @Success 200 {object} store.AdminAuditPage -// @Failure 400,401,500 {object} CoreErrorResponse -// @Router /core/v1/audit-log [get] -func (h *Handler) listAdminAudit(w http.ResponseWriter, r *http.Request) { - values, err := url.ParseQuery(r.URL.RawQuery) - if err != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - for name, entries := range values { - switch name { - case "project_id", "resource_type", "resource_id", "action", "created_after", "created_before", "limit", "after": - default: - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if len(entries) != 1 || entries[0] == "" { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - } - filter := store.AdminAuditFilter{ProjectID: values.Get("project_id"), ResourceType: values.Get("resource_type"), ResourceID: values.Get("resource_id"), Action: values.Get("action"), After: values.Get("after"), Limit: 50} - if limit := values.Get("limit"); limit != "" { - filter.Limit, err = strconv.Atoi(limit) - if err != nil || filter.Limit < 1 || filter.Limit > 100 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - } - filter.CreatedAfter, err = adminSummaryTime(r, "created_after") - if err != nil { - writeStoreError(w, r, err) - return - } - filter.CreatedBefore, err = adminSummaryTime(r, "created_before") - if err != nil { - writeStoreError(w, r, err) - return - } - page, err := h.adminManagement.ListAdminAudit(r.Context(), filter) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, page) -} diff --git a/services/agents-api/internal/api/admin_session_archive.go b/services/agents-api/internal/api/admin_session_archive.go deleted file mode 100644 index 66a592a83..000000000 --- a/services/agents-api/internal/api/admin_session_archive.go +++ /dev/null @@ -1,71 +0,0 @@ -package api - -import ( - "context" - "net/http" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -// WithSessionArchive binds the execution owner; management reads use the ordinary Store. -func WithSessionArchive(archive func(context.Context, string, string, uint64) (store.ManagedSessionArchive, error)) Option { - return func(h *Handler) { h.adminArchive = archive } -} - -type AdminSessionArchiveRequest struct { - ExpectedGeneration uint64 `json:"expected_generation"` -} - -// @Summary Release a managed Session's execution resources while retaining history -// @Description Core key only. Requires the current deployment generation; no maintenance mode is required. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. -// @Tags Core Administration -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param session_id path string true "Session ID" -// @Param body body api.AdminSessionArchiveRequest true "Current deployment generation" -// @Success 200 {object} store.ManagedSessionArchive -// @Failure 400,401,404,409,413,500,503 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [post] -func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONBodyLimit(w, r, 4096, "Archive request is too large.") - if !ok { - return - } - var input AdminSessionArchiveRequest - if decodeInputObject(raw, &input, "expected_generation") != nil || input.ExpectedGeneration == 0 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if h.adminArchive == nil { - writeStoreError(w, r, store.ErrEnvironmentUnavailable) - return - } - result, err := h.adminArchive(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), input.ExpectedGeneration) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -// @Summary Retrieve a managed Session's resource cleanup state -// @Description Core key only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param session_id path string true "Session ID" -// @Success 200 {object} store.ManagedSessionArchive -// @Failure 400,401,404,500,503 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [get] -func (h *Handler) adminGetSessionArchive(w http.ResponseWriter, r *http.Request) { - result, err := h.adminManagement.GetManagedSessionArchive(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} diff --git a/services/agents-api/internal/api/admin_session_archive_test.go b/services/agents-api/internal/api/admin_session_archive_test.go deleted file mode 100644 index 28e24eb82..000000000 --- a/services/agents-api/internal/api/admin_session_archive_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type archiveManagementFixture struct { - AdminManagementStore - tenant, session string - generation uint64 - calls int - audited, impersonated bool - err error -} - -func (s *archiveManagementFixture) ArchiveManagedSession(ctx context.Context, tenant, session string, generation uint64) (store.ManagedSessionArchive, error) { - s.calls++ - s.tenant, s.session, s.generation = tenant, session, generation - source, ok := adminaudit.FromContext(ctx) - s.audited = ok && source.ProjectID == managementProjectID && source.CredentialID != "" && source.RequestID != "" - s.impersonated = ctx.Value(principalContextKey{}) != nil - return store.ManagedSessionArchive{SessionID: session, EnvironmentID: "environment", State: "cleanup_pending"}, s.err -} - -func (s *archiveManagementFixture) GetManagedSessionArchive(_ context.Context, tenant, session string) (store.ManagedSessionArchive, error) { - s.calls++ - s.tenant, s.session = tenant, session - return store.ManagedSessionArchive{SessionID: session, EnvironmentID: "environment", State: "released"}, s.err -} - -func TestAdminSessionArchiveAuthorityAndValidation(t *testing.T) { - key := callerBinding() - auth, _ := NewAuthenticator([]APIKey{key}) - admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) - fixture := &archiveManagementFixture{} - h, err := NewHandler(&recordingStore{}, auth, "codex", WithProjectAPIKeys(managementProjectStore(key), admin), WithAdminManagement(fixture), WithSessionArchive(fixture.ArchiveManagedSession)) - if err != nil { - t.Fatal(err) - } - path := "/core/v1/projects/" + managementProjectID + "/sessions/11111111-1111-4111-8111-111111111111/archive" - for _, body := range []string{`{}`, `{"expected_generation":null}`, `{"expected_generation":0}`, `{"expected_generation":-1}`, `{"expected_generation":1.5}`, `{"expected_generation":"1"}`, `{"Expected_Generation":1}`} { - if w := projectKeyHTTP(h, http.MethodPost, path, "admin", body); w.Code != 400 { - t.Fatalf("invalid archive body accepted: %s: %d %s", body, w.Code, w.Body) - } - } - for _, token := range []string{"", "caller", "issued-project-key"} { - for _, method := range []string{http.MethodGet, http.MethodPost} { - if w := projectKeyHTTP(h, method, path, token, `{"expected_generation":1}`); w.Code != 401 { - t.Fatalf("archive authorized %q: %d", token, w.Code) - } - } - } - if fixture.calls != 0 { - t.Fatal("invalid request reached store") - } - w := projectKeyHTTP(h, http.MethodPost, path, "admin", `{"expected_generation":2}`) - var result store.ManagedSessionArchive - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.State != "cleanup_pending" { - t.Fatalf("archive: %d %s", w.Code, w.Body) - } - if fixture.tenant != key.TenantID || fixture.generation != 2 || !fixture.audited || fixture.impersonated { - t.Fatalf("incorrect administrative target: %+v", fixture) - } - w = projectKeyHTTP(h, http.MethodGet, path, "admin", "") - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.State != "released" { - t.Fatalf("archive status: %d %s", w.Code, w.Body) - } - for _, failure := range []struct { - err error - status int - }{{store.ErrSandboxDeploymentConflict, 409}, {store.ErrNotFound, 404}, {store.ErrInvalidInput, 400}} { - fixture.err = failure.err - if w := projectKeyHTTP(h, http.MethodPost, path, "admin", `{"expected_generation":2}`); w.Code != failure.status { - t.Fatalf("archive error: %d %s", w.Code, w.Body) - } - } -} diff --git a/services/agents-api/internal/api/admin_summary.go b/services/agents-api/internal/api/admin_summary.go deleted file mode 100644 index 473036858..000000000 --- a/services/agents-api/internal/api/admin_summary.go +++ /dev/null @@ -1,199 +0,0 @@ -package api - -import ( - "context" - "net/http" - "sort" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type AdminSessionCounts struct { - Total int64 `json:"total"` - Idle int64 `json:"idle"` - InProgress int64 `json:"in_progress"` - RequiresAction int64 `json:"requires_action"` - Failed int64 `json:"failed"` -} -type AdminUsageCoverage struct { - MeasuredSessions int64 `json:"measured_sessions"` - TotalSessions int64 `json:"total_sessions"` - Ratio *float64 `json:"ratio"` -} -type AdminSummaryRow struct { - ProjectID string `json:"project_id"` - KeyID *string `json:"key_id"` - AgentID *string `json:"agent_id"` - Assets *store.AdminAssetCounts `json:"assets"` - Sessions AdminSessionCounts `json:"sessions"` - Usage v1.TokenUsage `json:"usage"` - Coverage AdminUsageCoverage `json:"coverage"` - LastActiveAt *int64 `json:"last_active_at"` -} -type AdminSummaryResponse struct { - Data []AdminSummaryRow `json:"data"` - HasMore bool `json:"has_more"` - NextCursor string `json:"next_cursor"` -} - -func adminSummaryTime(r *http.Request, name string) (*time.Time, error) { - values, ok := r.URL.Query()[name] - if !ok { - return nil, nil - } - if len(values) != 1 || values[0] == "" { - return nil, store.ErrInvalidInput - } - value, err := time.Parse(time.RFC3339Nano, values[0]) - if err != nil { - return nil, store.ErrInvalidInput - } - return &value, nil -} - -// @Summary Summarize resource counts and Session usage by Project, Agent or creator key -// @Description Core key only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id query string false "One API Project" -// @Param group_by query string false "Grouping" Enums(project,agent,key) default(project) -// @Param created_after query string false "Inclusive RFC3339 Session creation time" -// @Param created_before query string false "Exclusive RFC3339 Session creation time" -// @Param after query string false "Last Project ID in preceding page" -// @Param limit query int false "Number of Projects" minimum(1) maximum(100) default(20) -// @Param order query string false "Project order" Enums(asc,desc) default(desc) -// @Success 200 {object} api.AdminSummaryResponse -// @Failure 400,401,404,500 {object} CoreErrorResponse -// @Router /core/v1/summary [get] -func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { - options, ok := readPage(w, r, "project_id", "group_by", "created_after", "created_before") - if !ok { - return - } - group := r.URL.Query().Get("group_by") - if group == "" { - group = "project" - } - if group != "project" && group != "agent" && group != "key" { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - after, err := adminSummaryTime(r, "created_after") - if err != nil { - writeStoreError(w, r, err) - return - } - before, err := adminSummaryTime(r, "created_before") - if err != nil { - writeStoreError(w, r, err) - return - } - if after != nil && before != nil && !after.Before(*before) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - if group == "agent" && r.URL.Query().Get("project_id") == "" { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - var projects store.ProjectPage - if projectID := r.URL.Query().Get("project_id"); projectID != "" { - if options.after != "" { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - var binding store.ProjectBinding - binding, err = h.resolveAdminProject(ctx, projectID) - projects = store.ProjectPage{Data: []store.Project{binding.Project}} - } else { - projects, err = h.listAdminProjects(ctx, options.after, options.limit, options.ascending) - } - if err != nil { - writeStoreError(w, r, err) - return - } - response := AdminSummaryResponse{Data: []AdminSummaryRow{}, HasMore: projects.HasMore} - for _, project := range projects.Data { - groups := map[string]*AdminSummaryRow{} - if group == "project" { - groups[""] = &AdminSummaryRow{ProjectID: project.ID} - } - counts, err := h.adminManagement.ReadAdminSummary(ctx, project.TenantID, store.AdminSummaryFilter{CreatedAfter: after, CreatedBefore: before}, func(session store.Session, creationKeyID *string) error { - projected, err := sessionResponse(session, h.executorURL) - if err != nil { - return err - } - groupID := "" - if group == "agent" { - groupID = projected.Agent.ID - } else if group == "key" && creationKeyID != nil { - groupID = *creationKeyID - } - row := groups[groupID] - if row == nil { - row = &AdminSummaryRow{ProjectID: project.ID} - if group == "agent" { - id := groupID - row.AgentID = &id - } else if group == "key" { - row.KeyID = creationKeyID - } - groups[groupID] = row - } - row.Sessions.Total++ - switch projected.Status { - case "idle": - row.Sessions.Idle++ - case "in_progress": - row.Sessions.InProgress++ - case "requires_action": - row.Sessions.RequiresAction++ - case "failed": - row.Sessions.Failed++ - } - if row.LastActiveAt == nil || projected.LastActiveAt > *row.LastActiveAt { - at := projected.LastActiveAt - row.LastActiveAt = &at - } - row.Coverage.TotalSessions++ - if usage := projected.Usage; usage != nil { - row.Coverage.MeasuredSessions++ - row.Usage.InputTokens += usage.InputTokens - row.Usage.OutputTokens += usage.OutputTokens - row.Usage.TotalTokens += usage.TotalTokens - row.Usage.InputTokensDetails.CachedTokens += usage.InputTokensDetails.CachedTokens - row.Usage.OutputTokensDetails.ReasoningTokens += usage.OutputTokensDetails.ReasoningTokens - } - return nil - }) - if err != nil { - writeStoreError(w, r, err) - return - } - if group == "project" { - groups[""].Assets = &counts - } - ids := make([]string, 0, len(groups)) - for id := range groups { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { - row := groups[id] - if row.Coverage.TotalSessions > 0 { - ratio := float64(row.Coverage.MeasuredSessions) / float64(row.Coverage.TotalSessions) - row.Coverage.Ratio = &ratio - } - response.Data = append(response.Data, *row) - } - } - if projects.HasMore && len(projects.Data) > 0 { - response.NextCursor = projects.Data[len(projects.Data)-1].ID - } - writeJSON(w, http.StatusOK, response) -} diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go deleted file mode 100644 index 77150d824..000000000 --- a/services/agents-api/internal/api/agents.go +++ /dev/null @@ -1,118 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -type AgentStore interface { - DeleteAgent(context.Context, string, string) (string, error) - UpdateAgent(context.Context, string, string, store.UpdateAgentInput) (store.SavedAgent, error) - ListAgents(context.Context, string, string, int, bool) (store.AgentPage, error) - CreateAgent(context.Context, string, store.CreateAgentInput) (store.SavedAgent, error) - GetAgent(context.Context, string, string) (store.SavedAgent, error) -} - -// @Summary Create a reusable Agent -// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. As on every Agents API JSON route, a non-JSON Content-Type, invalid UTF-8, malformed JSON, a repeated key at any depth or a non-object root returns invalid_request_error with a null param and the official message before other checks; an empty or null body is {}. Missing, unknown, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id, service origin (omitted or null on HTTP transport is saved as service) and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location, including {}, includes all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. -// @Tags Agents -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param body body v1.CreateAgentRequest true "Reusable Agent configuration" -// @Success 201 {object} v1.SavedAgent -// @Failure 400,401,413,500 {object} v1.ErrorResponse -// @Router /agents [post] -func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObject(w, r) - if !ok { - return - } - if writeFieldError(w, metadataTypeError(raw)) || writeFieldError(w, validateSavedAgentBody(raw, savedAgentCreate)) { - return - } - if err := validateSavedCoreInput(raw); err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) - return - } - var request v1.CreateAgentRequest - if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return - } - input, err := resolveSavedAgent(request) - if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) - } - return - } - agent, err := h.store.CreateAgent(r.Context(), tenantID(r), input) - if err != nil { - writeStoreError(w, r, err) - return - } - h.respondAgentStatus(w, r, agent, http.StatusCreated) -} - -// @Summary Retrieve a reusable Agent -// @Description Reads the saved resource owned by the authenticated tenant, independently of execution Sessions. -// @Tags Agents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param agent_id path string true "Agent ID" -// @Success 200 {object} v1.SavedAgent -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/{agent_id} [get] -func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { - agent, err := h.lookupAgent(r.Context(), tenantID(r), chi.URLParam(r, "agent_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - h.respondAgent(w, r, agent) -} - -func (h *Handler) respondAgent(w http.ResponseWriter, r *http.Request, agent store.SavedAgent) { - h.respondAgentStatus(w, r, agent, http.StatusOK) -} - -func (h *Handler) respondAgentStatus(w http.ResponseWriter, r *http.Request, agent store.SavedAgent, status int) { - response, err := agentResponse(agent) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, status, response) -} - -func agentResponse(agent store.SavedAgent) (v1.SavedAgent, error) { - var response v1.SavedAgent - if err := json.Unmarshal(agent.Configuration, &response.SavedAgentConfiguration); err != nil { - return response, err - } - response.ID, response.Object = agent.ID, "agent" - response.Metadata = agent.Metadata - response.CreatedAt, response.UpdatedAt = agent.CreatedAt.Unix(), agent.UpdatedAt.Unix() - return response, nil -} - -func (h *Handler) lookupAgent(ctx context.Context, tenant, id string) (store.SavedAgent, error) { - if !validAgentID(id) { - return store.SavedAgent{}, store.ErrNotFound - } - return h.store.GetAgent(ctx, tenant, id) -} - -func validAgentID(id string) bool { - parsed, err := uuid.Parse(id) - return err == nil && parsed != uuid.Nil -} diff --git a/services/agents-api/internal/api/agents_delete.go b/services/agents-api/internal/api/agents_delete.go deleted file mode 100644 index 85b4d876b..000000000 --- a/services/agents-api/internal/api/agents_delete.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "bytes" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -// @Summary Delete a reusable Agent -// @Description Deletes only the authenticated tenant's saved configuration. Existing Session snapshots, history and recorded creation retry identities remain independent. Missing and repeated deletion locally return404; exact hosted error and in-flight creation/deletion semantics remain unverified. -// @Tags Agents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param agent_id path string true "Agent ID" -// @Success 200 {object} v1.AgentDeleted -// @Failure 400,401,404,413,500 {object} v1.ErrorResponse -// @Router /agents/{agent_id} [delete] -func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) { - body, ok := readJSONBody(w, r) - if !ok { - return - } - if len(bytes.TrimSpace(body)) > 0 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent deletion does not accept a request body.") - return - } - id := chi.URLParam(r, "agent_id") - if !validAgentID(id) { - writeStoreError(w, r, store.ErrNotFound) - return - } - deleted, err := h.store.DeleteAgent(r.Context(), tenantID(r), id) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.AgentDeleted{ID: deleted, Object: "agent.deleted", Deleted: true}) -} diff --git a/services/agents-api/internal/api/agents_list.go b/services/agents-api/internal/api/agents_list.go deleted file mode 100644 index 8793d8666..000000000 --- a/services/agents-api/internal/api/agents_list.go +++ /dev/null @@ -1,45 +0,0 @@ -package api - -import ( - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// @Summary List reusable Agents -// @Description Lists only the authenticated tenant's saved Agents, independently of Sessions. Limit 0 is treated as 1 and larger limits as 100, as observed on the hosted service. The local default is 20; exact upstream default/cap and empty cursor fields remain unverified. An unknown, malformed or foreign after cursor returns not found. -// @Tags Agents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param after query string false "Last Agent ID from the previous page" -// @Param limit query int64 false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) -// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.SavedAgentList -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents [get] -func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { - options, ok := readClampedPage(w, r) - if !ok { - return - } - page, err := h.store.ListAgents(r.Context(), tenantID(r), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - response := v1.SavedAgentList{Object: "list", Data: make([]v1.SavedAgent, 0, len(page.Agents)), HasMore: page.NextCursor != ""} - for _, agent := range page.Agents { - item, err := agentResponse(agent) - if err != nil { - writeStoreError(w, r, err) - return - } - response.Data = append(response.Data, item) - } - if len(response.Data) > 0 { - response.FirstID = &response.Data[0].ID - response.LastID = &response.Data[len(response.Data)-1].ID - } - writeJSON(w, http.StatusOK, response) -} diff --git a/services/agents-api/internal/api/agents_update.go b/services/agents-api/internal/api/agents_update.go deleted file mode 100644 index fddb61238..000000000 --- a/services/agents-api/internal/api/agents_update.go +++ /dev/null @@ -1,109 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -// @Summary Update a reusable Agent -// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Name, metadata and configuration validation errors return invalid_request_error with the official param, using the Agent create rules before the Agent lookup. Existing Session snapshots are unchanged. Empty updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. -// @Tags Agents -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param agent_id path string true "Agent ID" -// @Param body body v1.UpdateAgentRequest true "Supplied reusable Agent fields" -// @Success 200 {object} v1.SavedAgent -// @Failure 400,401,404,413,500 {object} v1.ErrorResponse -// @Router /agents/{agent_id} [post] -func (h *Handler) updateAgent(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObject(w, r) - if !ok { - return - } - input, err := resolveAgentUpdate(raw) - if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) - } - return - } - id := chi.URLParam(r, "agent_id") - if !validAgentID(id) { - // Storage validation precedes the lookup; follow the missing-Agent path. - id = store.UnknownResourceID - } - updated, err := h.store.UpdateAgent(r.Context(), tenantID(r), id, input) - if err != nil { - writeStoreError(w, r, err) - return - } - h.respondAgent(w, r, updated) -} - -func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { - if err := metadataTypeError(raw); err != nil { - return store.UpdateAgentInput{}, err - } - if err := validateSavedAgentBody(raw, savedAgentUpdate); err != nil { - return store.UpdateAgentInput{}, err - } - if err := validateSavedCoreInput(raw); err != nil { - return store.UpdateAgentInput{}, err - } - var request v1.UpdateAgentRequest - if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { - return store.UpdateAgentInput{}, errors.New("Request must be a JSON object containing supported fields.") - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return store.UpdateAgentInput{}, err - } - if _, supplied := fields["model"]; supplied && request.Model == nil { - return store.UpdateAgentInput{}, errors.New("model must be a string when supplied.") - } - normalized, err := resolveSavedFields(v1.CreateAgentRequest(request)) - if err != nil { - return store.UpdateAgentInput{}, err - } - var patch map[string]json.RawMessage - if err := json.Unmarshal(normalized.Configuration, &patch); err != nil { - return store.UpdateAgentInput{}, err - } - if _, supplied := fields["x_agents_core"]; supplied && request.XAgentsCore == nil { - patch["x_agents_core"] = json.RawMessage(`null`) - } - for field := range patch { - if _, supplied := fields[field]; !supplied { - delete(patch, field) - } - } - result := store.UpdateAgentInput{ModelProvider: normalized.ModelProvider} - if extension, supplied := fields["x_agents_core"]; supplied { - if request.XAgentsCore == nil { - result.ModelProviderSet = true - } else { - _, coreFields := orderedMembers(extension) - _, result.ModelProviderSet = coreFields["model_provider"] - if result.ModelProviderSet && request.XAgentsCore.ModelProvider == nil { - _, corePatch := orderedMembers(patch["x_agents_core"]) - corePatch["model_provider"] = json.RawMessage(`null`) - patch["x_agents_core"], err = json.Marshal(corePatch) - if err != nil { - return store.UpdateAgentInput{}, err - } - } - } - } - if _, supplied := fields["metadata"]; supplied { - result.Metadata = &normalized.Metadata - } - result.Configuration, err = json.Marshal(patch) - return result, err -} diff --git a/services/agents-api/internal/api/auth_fixture_test.go b/services/agents-api/internal/api/auth_fixture_test.go deleted file mode 100644 index 21f9ab040..000000000 --- a/services/agents-api/internal/api/auth_fixture_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package api - -import ( - "context" - "encoding/hex" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type APIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding - -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - if b, ok := f[digest]; ok { - return b, nil - } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound -} -func NewAuthenticator(keys []APIKey) (*Authenticator, error) { - resolver := fixtureKeyResolver{} - for _, k := range keys { - p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} - if err := p.Validate(); err != nil { - return nil, err - } - digest, err := hex.DecodeString(k.TokenSHA256) - if err != nil || len(digest) != 32 { - return nil, errors.New("invalid fixture digest") - } - if _, exists := resolver[k.TokenSHA256]; exists { - return nil, errors.New("duplicate fixture digest") - } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} - } - return NewDatabaseAuthenticator(resolver) -} diff --git a/services/agents-api/internal/api/claude_mcp_test.go b/services/agents-api/internal/api/claude_mcp_test.go deleted file mode 100644 index e40ad308f..000000000 --- a/services/agents-api/internal/api/claude_mcp_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package api - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type claudeCredentialStore struct { - recordingStore - binding store.MCPCredentialBinding - calls int -} - -func (s *claudeCredentialStore) ResolveMCPCredentials(_ context.Context, _ string, _ []string, _ []store.MCPCredentialRequest) ([]store.MCPCredentialBinding, error) { - s.calls++ - return []store.MCPCredentialBinding{s.binding}, nil -} - -func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) { - for _, selection := range []string{"implicit", "explicit", "unmatched"} { - t.Run(selection, func(t *testing.T) { - vault, credential := uuid.NewString(), uuid.NewString() - s := &claudeCredentialStore{binding: store.MCPCredentialBinding{ServerLabel: "records", ServerURL: "https://mcp.example.test/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}} - tool := publicMCP - if selection == "explicit" { - tool = strings.TrimSuffix(tool, "}") + `,"credential_id":"` + credential + `"}` - } - if selection == "unmatched" { - s.binding.VaultID, s.binding.CredentialID, s.binding.AuthType = "", "", "" - } - digest := sha256.Sum256([]byte("test-api-key")) - auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(digest[:]), TenantID: uuid.NewString()}}) - if err != nil { - t.Fatal(err) - } - h, err := NewHandler(s, auth, "claude_sdk", WithExecution(&inputRecorder{ResourceStore: s})) - if err != nil { - t.Fatal(err) - } - body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q],"input":"Use the configured records server."}`, tool, vault) - response := credentialRequest(h, "POST", "/v1/agents/sessions", body) - if response.Code != 201 || s.calls != 1 || s.tenant == "" { - t.Fatal("credential selection or admission failed", response.Code, response.Body, s.calls) - } - }) - } -} diff --git a/services/agents-api/internal/api/core_metrics.go b/services/agents-api/internal/api/core_metrics.go deleted file mode 100644 index b40106bae..000000000 --- a/services/agents-api/internal/api/core_metrics.go +++ /dev/null @@ -1,66 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/url" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" -) - -type CoreMetricsService interface { - Read(context.Context, string) (coremetrics.View, error) - RecordUnavailable() -} - -func WithCoreMetrics(service CoreMetricsService) Option { - return func(h *Handler) { h.coreMetrics = service } -} - -// @Summary Retrieve Core operational metrics -// @Description Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Param range query string false "Time range (default 1h)" Enums(1h,6h,24h,7d) -// @Success 200 {object} coremetrics.View -// @Failure 400,401,503 {object} CoreErrorResponse -// @Router /core/v1/metrics [get] -func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { - values, err := url.ParseQuery(r.URL.RawQuery) - if err != nil || len(values) > 1 || (len(values) == 1 && len(values["range"]) != 1) { - writeError(w, http.StatusBadRequest, "invalid_request", "Only one supported range parameter is allowed.") - return - } - name := "1h" - if v, ok := values["range"]; ok { - name = v[0] - } - switch name { - case "1h", "6h", "24h", "7d": - default: - writeError(w, http.StatusBadRequest, "invalid_request", "range must be 1h, 6h, 24h or 7d.") - return - } - if h.coreMetrics == nil { - writeError(w, http.StatusServiceUnavailable, "core_metrics_unavailable", "Core metrics are not configured.") - return - } - value, err := h.coreMetrics.Read(r.Context(), name) - if err != nil { - writeError(w, http.StatusServiceUnavailable, "core_metrics_unavailable", "Core metrics could not be read.") - return - } - writeJSON(w, http.StatusOK, value) -} - -func (h *Handler) responseHeaders(next http.Handler) http.Handler { - if h.coreMetrics == nil { - return agentsResponseHeaders(next) - } - return responseHeadersWithErrors(next, func(code string) { - if code == "execution_unavailable" { - h.coreMetrics.RecordUnavailable() - } - }) -} diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go deleted file mode 100644 index c41b1de85..000000000 --- a/services/agents-api/internal/api/environment_executor_management.go +++ /dev/null @@ -1,173 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -// EnvironmentExecutorStore manages the executor credentials of a Project's -// self_hosted Environments. The Project's principal is the credential's -// execution principal; the Core key that authorizes the request is not. -type EnvironmentExecutorStore interface { - ProjectExecutorCredentialState(context.Context, identity.Principal, string) (store.ExecutorCredentialState, error) - IssueProjectExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) - RevokeProjectExecutorCredential(context.Context, identity.Principal, string, string) error -} - -type EnvironmentExecutorCredentialRequest struct { - KeyID string `json:"key_id" format:"uuid"` - Rotate bool `json:"rotate,omitempty"` -} - -// ExecutorCredentialList holds credential metadata only, never a secret. -type ExecutorCredentialList struct { - Data []store.ExecutorCredential `json:"data" binding:"required"` - Connection ExecutorConnection `json:"connection" binding:"required"` -} - -// ExecutorConnection reports binding history and current Core-observed connectivity. -// Heartbeat times are observations, not execution or native readiness. -type ExecutorConnection struct { - Status string `json:"status" binding:"required" enums:"never_enrolled,connected,disconnected"` - BoundKeyID *string `json:"bound_key_id" binding:"required" extensions:"x-nullable" format:"uuid"` - EnrolledAt *time.Time `json:"enrolled_at" binding:"required" format:"date-time" extensions:"x-nullable"` - LastSeenAt *time.Time `json:"last_seen_at" binding:"required" format:"date-time" extensions:"x-nullable"` -} - -// WithExecutorConnections observes current authority and its actual gateway peer. -// The observer runs after the store snapshot closes and must recheck authority -// after inspecting the peer. Without an observer, no binding is called connected. -func WithExecutorConnections(observe func(context.Context, string, string) (bool, error)) Option { - return func(h *Handler) { h.executorConnections = observe } -} - -// registerExecutorCredentialRoutes adds executor credential issuance to the -// Core-key-authenticated /core/v1 router. -func (h *Handler) registerExecutorCredentialRoutes(r chi.Router) { - s, ok := h.store.(EnvironmentExecutorStore) - if !ok || h.projectKeys == nil { - return - } - const path = "/projects/{project_id}/environments/{environment_id}/executor-credentials" - r.Get("/projects/{project_id}/environments/{environment_id}/installation", h.getEnvironmentInstallation) - r.Get(path, func(w http.ResponseWriter, r *http.Request) { h.listExecutorCredentials(w, r, s) }) - r.Post(path, func(w http.ResponseWriter, r *http.Request) { h.issueExecutorCredential(w, r, s) }) - r.Delete(path+"/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeExecutorCredential(w, r, s) }) -} - -// @Summary List a self_hosted Environment's executor credentials -// @Description Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. Connection combines current credential authority and an open matching gateway peer; timestamps are historical observations, not readiness. Without a gateway it is never connected. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. -// @Tags Executor Credentials -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param environment_id path string true "Environment UUID" -// @Success 200 {object} api.ExecutorCredentialList -// @Failure 401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [get] -func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - state, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, chi.URLParam(r, "environment_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - connection := ExecutorConnection{Status: "never_enrolled"} - observed := state.Connection - if observed.DeviceID != "" { - connection = ExecutorConnection{Status: "disconnected", BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt, LastSeenAt: observed.LastSeenAt} - if observed.EnvironmentStatus == "connected" && observed.CredentialHash != "" && h.executorConnections != nil { - connected, err := h.executorConnections(r.Context(), state.EnvironmentID, observed.CredentialHash) - if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, store.ErrDeviceBindingConflict) { - writeStoreError(w, r, err) - return - } - if err == nil && connected { - connection.Status = "connected" - } - } - } - writeJSON(w, http.StatusOK, ExecutorCredentialList{Data: state.Credentials, Connection: connection}) -} - -// @Summary Issue or explicitly rotate a self_hosted Environment executor credential -// @Description Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. In an archived Project, issuance and rotation return 409 project_archived. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. -// @Tags Executor Credentials -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param environment_id path string true "Environment UUID" -// @Param body body api.EnvironmentExecutorCredentialRequest true "Request" -// @Success 201 {object} store.IssuedExecutorCredential -// @Failure 400,401,404,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [post] -func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { - // Check order: request body (400), target (404), archived Project (409), - // then the key itself (409 exists, or 404 for rotating an unknown key). - raw, ok := readJSONBody(w, r) - if !ok { - return - } - var input EnvironmentExecutorCredentialRequest - var fields map[string]json.RawMessage - if decodeInputObject(raw, &input, "key_id", "rotate") != nil || json.Unmarshal(raw, &fields) != nil || bytes.Equal(bytes.TrimSpace(fields["rotate"]), []byte("null")) || !executorManagementID(input.KeyID) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - credential, err := s.IssueProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusCreated, credential) -} - -// @Summary Revoke a self_hosted Environment executor credential -// @Description Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe and it also works in an archived Project. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. -// @Tags Executor Credentials -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param environment_id path string true "Environment UUID" -// @Param key_id path string true "Executor key UUID" -// @Success 204 -// @Failure 401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id} [delete] -func (h *Handler) revokeExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - keyID := chi.URLParam(r, "key_id") - if !executorManagementID(keyID) { - writeStoreError(w, r, store.ErrNotFound) - return - } - if err := s.RevokeProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), keyID); err != nil { - writeStoreError(w, r, err) - return - } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) -} - -func executorManagementID(value string) bool { - id, err := uuid.Parse(value) - return err == nil && id != uuid.Nil && id.String() == value -} diff --git a/services/agents-api/internal/api/environment_installation.go b/services/agents-api/internal/api/environment_installation.go deleted file mode 100644 index 27d2d1666..000000000 --- a/services/agents-api/internal/api/environment_installation.go +++ /dev/null @@ -1,183 +0,0 @@ -package api - -import ( - "context" - "net/http" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type environmentInstallationStore interface { - AuthorizeEnvironmentInstallation(context.Context, identity.Principal, string, string) (string, int64, error) - ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) - ClaimEnvironmentInstallation(context.Context, string, string, string) error -} - -func WithNativeInstaller(catalog *nativeinstaller.Catalog, version string) Option { - return func(h *Handler) { h.nativeInstaller, h.nativeVersion = catalog, version } -} - -func (h *Handler) installationFor(ctx context.Context, principal identity.Principal, environment string) (*v1.EnvironmentInstallation, error) { - result := &v1.EnvironmentInstallation{Status: "unavailable", Version: h.nativeVersion, Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} - s, ok := h.store.(environmentInstallationStore) - if !ok || h.nativeInstaller == nil { - return result, nil - } - token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, principal, environment, h.nativeVersion) - if err != nil { - return nil, err - } - origin := strings.TrimSuffix(h.executorURL, "/api/v1/agent-daemon/ws") - origin = strings.Replace(strings.Replace(origin, "wss://", "https://", 1), "ws://", "http://", 1) - return &v1.EnvironmentInstallation{Status: "available", Version: h.nativeVersion, ExpiresAt: expires, Commands: h.nativeInstaller.Commands(origin, token)}, nil -} - -func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { - if response.Environment.Type != "self_hosted" || h.nativeVersion == "" { - return nil - } - principal, ok := r.Context().Value(principalContextKey{}).(identity.Principal) - if !ok { - return nil - } - installation, err := h.installationFor(r.Context(), principal, response.Environment.ID) - if err != nil { - return err - } - w.Header().Set("Cache-Control", "no-store") - response.XAgentsCore = &v1.SessionCore{Installation: installation} - return nil -} - -func (h *Handler) registerNativeInstallationRoutes(r chi.Router) { - if h.nativeVersion == "" { - return - } - if h.nativeInstaller != nil { - r.Handle("/api/v1/agent-daemon/install/*", h.nativeInstaller) - } - r.Post("/api/v1/agent-daemon/installation", h.prepareNativeInstallation) - r.Post("/api/v1/agent-daemon/installation/claim", h.claimNativeInstallation) -} - -func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Request) (environmentInstallationStore, store.InstallationAuthorization, string, bool) { - w.Header().Set("Cache-Control", "no-store") - s, ok := h.store.(environmentInstallationStore) - if !ok || h.nativeInstaller == nil { - writeError(w, 503, "installation_unavailable", "Matching native installation artifacts are unavailable.") - return nil, store.InstallationAuthorization{}, "", false - } - parts := strings.Fields(r.Header.Get("Authorization")) - if len(r.Header.Values("Authorization")) != 1 || len(parts) != 2 || parts[0] != "Bearer" { - writeStoreError(w, r, store.ErrInstallationAuthorization) - return nil, store.InstallationAuthorization{}, "", false - } - claim, err := s.ValidateEnvironmentInstallation(r.Context(), parts[1], h.nativeVersion) - if err != nil { - writeStoreError(w, r, err) - return nil, claim, "", false - } - return s, claim, parts[1], true -} - -// @Summary Resolve a native installation authorization -// @Description Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. -// @Tags Native Installation -// @Produce json -// @Success 200 {object} v1.NativeInstallationContext -// @Failure 401,404,503 {object} CoreErrorResponse -// @Router /api/v1/agent-daemon/installation [post] -func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Request) { - _, claim, _, ok := h.installationAuthorization(w, r) - if !ok { - return - } - environment, err := h.store.GetEnvironment(r.Context(), claim.Principal.TenantID, claim.Environment) - if err != nil { - writeStoreError(w, r, err) - return - } - session, err := h.store.GetSession(r.Context(), claim.Principal.TenantID, environment.SessionID) - if err != nil { - writeStoreError(w, r, err) - return - } - response, err := sessionResponse(session, h.executorURL) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.NativeInstallationContext{Version: h.nativeVersion, ProtocolVersion: proto.Version, EnvironmentID: claim.Environment, RemoteURL: h.executorURL, Workspace: response.Environment.WorkspaceDirectory, Harness: session.Engine}) -} - -type NativeInstallationClaim struct { - ExecutorToken string `json:"executor_token"` -} - -// @Summary Claim an Environment's installation credential -// @Description A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. -// @Tags Native Installation -// @Accept json -// @Param body body api.NativeInstallationClaim true "Locally persisted executor secret" -// @Success 204 -// @Failure 400,401,409,503 {object} CoreErrorResponse -// @Router /api/v1/agent-daemon/installation/claim [post] -func (h *Handler) claimNativeInstallation(w http.ResponseWriter, r *http.Request) { - s, _, token, ok := h.installationAuthorization(w, r) - if !ok { - return - } - raw, ok := readJSONBody(w, r) - if !ok { - return - } - var input NativeInstallationClaim - if decodeInputObject(raw, &input, "executor_token") != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if err := s.ClaimEnvironmentInstallation(r.Context(), token, h.nativeVersion, input.ExecutorToken); err != nil { - writeStoreError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// @Summary Get a self_hosted Session's installation commands -// @Description Core key only. The commands contain a 30-minute installation authorization, never an executor secret. Web displays these same commands provided in public Session creation and detail responses. -// @Tags Native Installation -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param environment_id path string true "Environment UUID" -// @Success 200 {object} v1.EnvironmentInstallation -// @Failure 401,404,409 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/environments/{environment_id}/installation [get] -func (h *Handler) getEnvironmentInstallation(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - environment := chi.URLParam(r, "environment_id") - s, ok := h.store.(EnvironmentExecutorStore) - if !ok { - writeStoreError(w, r, store.ErrNotFound) - return - } - if _, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, environment); err != nil { - writeStoreError(w, r, err) - return - } - result, err := h.installationFor(r.Context(), binding.Principal, environment) - if err != nil { - writeStoreError(w, r, err) - return - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, result) -} diff --git a/services/agents-api/internal/api/environment_installation_test.go b/services/agents-api/internal/api/environment_installation_test.go deleted file mode 100644 index 6e25ce03a..000000000 --- a/services/agents-api/internal/api/environment_installation_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type installationFixture struct { - environmentCreationFixture - authorizedEnvironment string -} - -func (f *installationFixture) AuthorizeEnvironmentInstallation(_ context.Context, p identity.Principal, environment, version string) (string, int64, error) { - if p.TenantID != f.session.TenantID || environment != f.session.Environment.ID || version != "build" { - return "", 0, store.ErrNotFound - } - f.authorizedEnvironment = environment - return "short-lived-install-grant", 2000000000, nil -} -func (f *installationFixture) ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) { - return store.InstallationAuthorization{}, store.ErrInstallationAuthorization -} -func (f *installationFixture) ClaimEnvironmentInstallation(context.Context, string, string, string) error { - return store.ErrInstallationAuthorization -} - -func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) { - f := &installationFixture{} - auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential("project-key"), TenantID: uuid.NewString()}}) - if err != nil { - t.Fatal(err) - } - handler, err := NewHandler(f, auth, "codex", withFixtureDeploymentProvider(), WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL("wss://core.example/api/v1/agent-daemon/ws"), WithNativeInstaller(&nativeinstaller.Catalog{Version: "build"}, "build")) - if err != nil { - t.Fatal(err) - } - body := `{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture-model"}}}` - r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) - r.Header.Set("Authorization", "Bearer project-key") - r.Header.Set("OpenAI-Beta", "agents=v1") - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - if w.Code != http.StatusCreated { - t.Fatal(w.Code, w.Body.String()) - } - var response v1.Session - if json.Unmarshal(w.Body.Bytes(), &response) != nil || response.XAgentsCore == nil || response.XAgentsCore.Installation == nil { - t.Fatal("installation omitted") - } - if f.authorizedEnvironment != response.Environment.ID || w.Header().Get("Cache-Control") != "no-store" { - t.Fatal("wrong authorization scope or caching") - } - for _, shell := range []string{"posix", "powershell"} { - command := response.XAgentsCore.Installation.Commands[shell] - if !strings.Contains(command, "short-lived-install-grant") || strings.Contains(command, "project-key") || strings.Contains(command, "fixture-model") { - t.Fatal("incorrect command authority") - } - } - request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-daemon/installation", nil) - request.Header.Set("Authorization", "Bearer "+response.Environment.ID) - w = httptest.NewRecorder() - handler.ServeHTTP(w, request) - if w.Code != http.StatusUnauthorized { - t.Fatal("Environment ID authenticated installation", w.Code) - } -} diff --git a/services/agents-api/internal/api/environment_plugins.go b/services/agents-api/internal/api/environment_plugins.go deleted file mode 100644 index 12308a6de..000000000 --- a/services/agents-api/internal/api/environment_plugins.go +++ /dev/null @@ -1,67 +0,0 @@ -package api - -import ( - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, error) { - if len(raw) == 0 { - return nil, nil - } - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { - return nil, store.ErrInvalidInput - } - result := make([]store.EnvironmentPlugin, 0, len(entries)) - for _, entry := range entries { - var input struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - Source json.RawMessage `json:"source"` - } - if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { - return nil, store.ErrInvalidInput - } - body, err := decodeCapabilityArchive(input.Source) - if err != nil { - return nil, err - } - result = append(result, store.EnvironmentPlugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) - } - return result, store.ValidateEnvironmentPlugins(result) -} - -func pluginResponse(plugins []agentplugin.Metadata) []json.RawMessage { - result := make([]json.RawMessage, 0, len(plugins)) - for _, plugin := range plugins { - raw, _ := json.Marshal(plugin) - result = append(result, raw) - } - return result -} - -func storedPlugins(raw json.RawMessage) ([]json.RawMessage, error) { - if len(raw) == 0 { - return []json.RawMessage{}, nil - } - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { - return nil, store.ErrInvalidInput - } - seen := map[string]bool{} - for _, entry := range entries { - var metadata agentplugin.Metadata - if decodeInputObject(entry, &metadata, "type", "name", "description") != nil || metadata.Type != "inline" || metadata.Name == "" || metadata.Description == "" || seen[metadata.Name] { - return nil, store.ErrInvalidInput - } - seen[metadata.Name] = true - } - if entries == nil { - entries = []json.RawMessage{} - } - return entries, nil -} diff --git a/services/agents-api/internal/api/environment_plugins_test.go b/services/agents-api/internal/api/environment_plugins_test.go deleted file mode 100644 index 21a824c1f..000000000 --- a/services/agents-api/internal/api/environment_plugins_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "encoding/base64" - "encoding/json" - "reflect" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func pluginInput(t *testing.T) json.RawMessage { - t.Helper() - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - for path, body := range map[string]string{ - ".codex-plugin/plugin.json": `{"name":"proof-plugin","description":"Two Skills.","skills":["./skills"]}`, - "skills/alpha/SKILL.md": "---\nname: alpha\ndescription: Alpha proof.\n---\nprivate-plugin-canary", - "skills/beta/SKILL.md": "---\nname: beta\ndescription: Beta proof.\n---\nUse ../../shared/data.txt", - "shared/data.txt": "private-plugin-resource", - } { - f, err := writer.Create("proof/" + path) - if err != nil { - t.Fatal(err) - } - if _, err = f.Write([]byte(body)); err != nil { - t.Fatal(err) - } - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof-plugin", "description": "Two Skills.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) - if err != nil { - t.Fatal(err) - } - return raw -} - -func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { - plugin := pluginInput(t) - raw := []byte(`{"plugins":[` + string(plugin) + `],"capability_directories":["/workspace/generated"]}`) - template, err := decodeTemplateInput(raw) - if err != nil || !template.SetPlugins || !template.SetDirectories || len(template.Initialization.Plugins) != 1 { - t.Fatal("template", err) - } - var decoded decodedSessionRequest - if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted",`+string(raw[1:])+`}`), &decoded); err != nil { - t.Fatal(err) - } - input, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { - t.Fatal("different installation inputs") - } - cfg, err := resolve(input, "tenant", "key", nil) - if err != nil || bytes.Contains(cfg, []byte(`"source"`)) || bytes.Contains(cfg, []byte("private-plugin")) { - t.Fatal("unsafe snapshot", err) - } - var snapshot struct { - Environment json.RawMessage `json:"environment"` - } - if err = json.Unmarshal(cfg, &snapshot); err != nil { - t.Fatal(err) - } - stored, err := storedEnvironment(snapshot.Environment) - if err != nil || len(stored.Plugins) != 1 || len(stored.CapabilityDirectories) != 1 { - t.Fatal("metadata", err) - } - env := store.Environment{ID: "environment", Status: "connected", Configuration: snapshot.Environment} - if response, err := environmentResponse(env); err != nil || len(response.Plugins) != 1 { - t.Fatal("environment response", err) - } - if response, err := hostedSessionEnvironment(env); err != nil || len(*response.Plugins) != 1 || len(*response.CapabilityDirectories) != 1 { - t.Fatal("session response", err) - } - lookup := &templateLookupStore{network: "enabled", plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} - h := Handler{store: lookup} - for _, override := range []string{"", `,"plugins":null,"capability_directories":null`, `,"plugins":[],"capability_directories":[]`} { - if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+override+`}}`), &decoded); err != nil { - t.Fatal(err) - } - in, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - intent, err := sessionCreationRequest(in, nil) - if err != nil || !bytes.Contains(intent, []byte("template")) { - t.Fatal("intent", err) - } - if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &in); err != nil { - t.Fatal(err) - } - want := 1 - if strings.Contains(override, "[]") { - want = 0 - } - if len(in.initialization.Plugins) != want || len(in.initialization.CapabilityDirectories) != want || len(in.Environment.Plugins) != want { - t.Fatal("inheritance/replacement") - } - } - for _, directory := range []string{`null`, `"/private"`, `"/workspace/../private"`, `"relative"`} { - if _, err := decodeTemplateInput([]byte(`{"capability_directories":[` + directory + `]}`)); err == nil { - t.Fatal("unsafe directory") - } - } - bad := strings.Replace(string(plugin), `"name":"proof-plugin"`, `"name":"mismatch"`, 1) - if _, err := decodeEnvironmentPlugins([]byte("[" + bad + "]")); err == nil { - t.Fatal("mismatched identity") - } - if _, err := decodeEnvironmentPlugins([]byte("[" + string(plugin) + "," + string(plugin) + "]")); err == nil { - t.Fatal("duplicate identity") - } -} diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go deleted file mode 100644 index 646335070..000000000 --- a/services/agents-api/internal/api/environment_setup.go +++ /dev/null @@ -1,122 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -var errSystemPackages = &fieldError{ - param: "packages.system", - message: "Runtime system package installation is not supported. Preinstall system dependencies in the sandbox image or template, or on the host machine.", -} - -func rejectSystemPackages(raw json.RawMessage) error { - var packages map[string]json.RawMessage - if json.Unmarshal(raw, &packages) == nil { - if _, supplied := packages["system"]; supplied { - return errSystemPackages - } - } - return nil -} - -func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { - var result store.EnvironmentSetup - if err := rejectSystemPackages(fields["packages"]); err != nil { - return result, err - } - if value, ok := fields["env"]; ok { - var entries map[string]*string - if json.Unmarshal(value, &entries) != nil { - return result, store.ErrInvalidInput - } - result.Env = make(map[string]string, len(entries)) - for name, entry := range entries { - if entry == nil { - return result, store.ErrInvalidInput - } - result.Env[name] = *entry - } - } - if value, ok := fields["setup_commands"]; ok { - var commands []json.RawMessage - if json.Unmarshal(value, &commands) != nil { - return result, store.ErrInvalidInput - } - for _, command := range commands { - var input struct { - Command *string `json:"command"` - CWD *string `json:"cwd"` - } - if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil { - return result, store.ErrInvalidInput - } - step := store.SetupCommand{Command: *input.Command} - if input.CWD != nil { - if *input.CWD == "" { - return result, store.ErrInvalidInput - } - step.CWD = *input.CWD - } - result.Commands = append(result.Commands, step) - } - } - if value, ok := fields["packages"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - var input struct { - NPM []*string `json:"npm"` - Python []*string `json:"python"` - } - if decodeInputObject(value, &input, "npm", "python") != nil { - return result, store.ErrInvalidInput - } - for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python} { - var target *[]string - switch name { - case "npm": - target = &result.Packages.NPM - case "python": - target = &result.Packages.Python - } - for _, entry := range entries { - if entry == nil { - return result, store.ErrInvalidInput - } - *target = append(*target, *entry) - } - } - } - var err error - result.Skills, err = decodeEnvironmentSkills(fields["skills"]) - if err != nil { - return result, err - } - result.Plugins, err = decodeEnvironmentPlugins(fields["plugins"]) - if err != nil { - return result, err - } - if raw, supplied := fields["capability_directories"]; supplied { - var entries []*string - if json.Unmarshal(raw, &entries) != nil { - return result, store.ErrInvalidInput - } - for _, entry := range entries { - if entry == nil { - return result, store.ErrInvalidInput - } - result.CapabilityDirectories = append(result.CapabilityDirectories, *entry) - } - } - return result, result.Validate() -} - -func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackagesResponse { - value := store.EnvironmentSetup{} - if packages != nil { - value.Packages = *packages - } - normalized := value.PackageMetadata() - return v1.EnvironmentPackagesResponse{NPM: normalized.NPM, Python: normalized.Python, System: []string{}} -} diff --git a/services/agents-api/internal/api/environment_skills.go b/services/agents-api/internal/api/environment_skills.go deleted file mode 100644 index 3e72e7763..000000000 --- a/services/agents-api/internal/api/environment_skills.go +++ /dev/null @@ -1,98 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, error) { - if len(raw) == 0 { - return nil, nil - } - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { - return nil, store.ErrInvalidInput - } - result := make([]store.EnvironmentSkill, 0, len(entries)) - for _, entry := range entries { - var discriminator struct { - Type string `json:"type"` - } - if json.Unmarshal(entry, &discriminator) != nil { - return nil, store.ErrInvalidInput - } - if discriminator.Type == "skill_reference" { - var reference struct { - Type string `json:"type"` - SkillID string `json:"skill_id"` - Version json.RawMessage `json:"version"` - } - if decodeInputObject(entry, &reference, "type", "skill_id", "version") != nil { - return nil, store.ErrInvalidInput - } - metadata := store.EnvironmentSkillMetadata{Type: reference.Type, SkillID: reference.SkillID} - if len(reference.Version) > 0 && !bytes.Equal(bytes.TrimSpace(reference.Version), []byte("null")) { - if json.Unmarshal(reference.Version, &metadata.Version) != nil || metadata.Version == "" { - return nil, store.ErrInvalidInput - } - } - result = append(result, store.EnvironmentSkill{Metadata: metadata}) - continue - } - var input struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - Source json.RawMessage `json:"source"` - } - if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { - return nil, store.ErrInvalidInput - } - body, err := decodeCapabilityArchive(input.Source) - if err != nil { - return nil, err - } - result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) - } - return result, store.ValidateEnvironmentSkills(result) -} - -func skillResponse(skills []store.EnvironmentSkillMetadata) []json.RawMessage { - result := make([]json.RawMessage, 0, len(skills)) - for _, skill := range skills { - var projection any = skill - if skill.Type == "skill_reference" && skill.Version == "" { - projection = struct { - store.EnvironmentSkillMetadata - Version *string `json:"version"` - }{EnvironmentSkillMetadata: skill} - } - raw, _ := json.Marshal(projection) - result = append(result, raw) - } - return result -} - -func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { - if len(raw) == 0 { - return []json.RawMessage{}, nil - } - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { - return nil, store.ErrInvalidInput - } - seen := map[string]bool{} - for _, entry := range entries { - var metadata store.EnvironmentSkillMetadata - if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || store.ValidateInstalledSkillMetadata(metadata) != nil || seen[metadata.Name] { - return nil, store.ErrInvalidInput - } - seen[metadata.Name] = true - } - if entries == nil { - entries = []json.RawMessage{} - } - return entries, nil -} diff --git a/services/agents-api/internal/api/environment_skills_test.go b/services/agents-api/internal/api/environment_skills_test.go deleted file mode 100644 index f6aa88a61..000000000 --- a/services/agents-api/internal/api/environment_skills_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "encoding/base64" - "encoding/json" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func skillInput(t *testing.T, body string) json.RawMessage { - t.Helper() - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - file, err := writer.Create("proof/SKILL.md") - if err != nil { - t.Fatal(err) - } - if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\n" + body)); err != nil { - t.Fatal(err) - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof", "description": "A proof.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) - if err != nil { - t.Fatal(err) - } - return raw -} - -func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { - lookup := &templateLookupStore{network: "enabled", skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} - h := Handler{store: lookup} - for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} { - var decoded decodedSessionRequest - if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+fields+`}}`), &decoded); err != nil { - t.Fatal(err) - } - input, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - intent, err := sessionCreationRequest(input, nil) - if err != nil || len(intent) == 0 { - t.Fatal("missing unresolved retry intent", err) - } - if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { - t.Fatal(err) - } - switch fields { - case "": - if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata != lookup.skills[0].Metadata { - t.Fatal("reference inheritance") - } - case `,"skills":[]`: - if len(input.initialization.Skills) != 0 { - t.Fatal("explicit empty list did not replace") - } - default: - if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata.SkillID != "skill-override" || input.initialization.Skills[0].Metadata.Version != "2" { - t.Fatal("reference replacement") - } - } - } - for _, version := range []string{"", `,"version":"latest"`, `,"version":"1"`} { - raw := []byte(`{"type":"openai_hosted","skills":[{"type":"skill_reference","skill_id":"skill-owned"` + version + `}]}`) - var decoded decodedSessionRequest - if err := json.Unmarshal(append(append([]byte(`{"agent":{"model":"test"},"environment":`), raw...), '}'), &decoded); err != nil { - t.Fatal(err) - } - input, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - intent, err := sessionCreationRequest(input, nil) - if err != nil || !bytes.Contains(intent, []byte("skill-owned")) { - t.Fatal("inline reference lost retry intent", err) - } - } - for _, version := range []string{`1`, `""`, `"0"`} { - if _, err := decodeEnvironmentSkills([]byte(`[{"type":"skill_reference","skill_id":"skill-owned","version":` + version + `}]`)); err == nil { - t.Fatal("invalid or unconfirmed selector accepted") - } - } -} - -func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { - skill := skillInput(t, "private-skill-canary") - raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) - template, err := decodeTemplateInput(raw) - if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { - t.Fatal("template", err) - } - environment := append([]byte(`{"type":"openai_hosted",`), raw[1:]...) - decode := func(agent string, environment []byte) sessionRequest { - var request decodedSessionRequest - if err := json.Unmarshal(append(append([]byte(`{`+agent+`,"environment":`), environment...), '}'), &request); err != nil { - t.Fatal(err) - } - input, err := request.validated() - if err != nil { - t.Fatal(err) - } - return input - } - inline := decode(`"agent":{"model":"test"}`, environment) - if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Initialization.Skills[0].Archive) { - t.Fatal("inline/template differ") - } - configuration, err := resolve(inline, "tenant", "key", nil) - if err != nil || bytes.Contains(configuration, []byte(`"source"`)) || bytes.Contains(configuration, []byte(`"archive"`)) { - t.Fatal("confidential snapshot", err) - } - public, err := storedEnvironment(mustEnvironment(t, configuration)) - if err != nil || len(public.Skills) != 1 || !bytes.Contains(public.Skills[0], []byte(`"name":"proof"`)) { - t.Fatal("metadata", err) - } - intent, err := sessionCreationRequest(decode(`"agent_id":"saved"`, environment), nil) - if err != nil { - t.Fatal(err) - } - changed := append(append([]byte(`{"type":"openai_hosted","skills":[`), skillInput(t, "different")...), []byte(`]}`)...) - other, err := sessionCreationRequest(decode(`"agent_id":"saved"`, changed), nil) - if err != nil || bytes.Equal(intent, other) { - t.Fatal("archive omitted from retry identity", err) - } - for _, clearing := range []string{`{"skills":null}`, `{"skills":[]}`} { - input, err := decodeTemplateInput([]byte(clearing)) - if err != nil || !input.SetSkills || !input.Initialization.Empty() { - t.Fatal("clear", err) - } - } - for _, invalid := range []string{`{"skills":[null]}`, `{"skills":[{"type":"skill_reference","skill_id":""}]}`, `{"skills":[{"type":"inline","name":"proof","description":"A proof.","source":{"type":"base64","media_type":"application/zip","data":"invalid"}}]}`} { - if _, err := decodeTemplateInput([]byte(invalid)); err == nil { - t.Fatal("invalid or unsupported skill accepted") - } - } - duplicate := append(append(append(append([]byte(`{"skills":[`), skill...), ','), skill...), []byte(`]}`)...) - if _, err := decodeTemplateInput(duplicate); err == nil { - t.Fatal("duplicate skill accepted") - } -} - -func mustEnvironment(t *testing.T, configuration []byte) json.RawMessage { - t.Helper() - var fields map[string]json.RawMessage - if err := json.Unmarshal(configuration, &fields); err != nil { - t.Fatal(err) - } - return fields["environment"] -} diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go deleted file mode 100644 index 105019546..000000000 --- a/services/agents-api/internal/api/environment_templates.go +++ /dev/null @@ -1,206 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "unicode/utf8" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type EnvironmentTemplateStore interface { - ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) - CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) - GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error) - UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) - DeleteEnvironmentTemplate(context.Context, string, string) (string, error) - ListEnvironmentTemplates(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) -} - -func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { - var fields map[string]json.RawMessage - if decodeInputObject(raw, &fields, "name", "network", "capability_directories", "env", "files", "packages", "plugins", "skills", "setup_commands") != nil { - return store.EnvironmentTemplateInput{}, store.ErrInvalidInput - } - in := store.EnvironmentTemplateInput{} - if value, supplied := fields["name"]; supplied { - in.SetName = true - if json.Unmarshal(value, &in.Name) != nil || (in.Name != nil && (!utf8.ValidString(*in.Name) || utf8.RuneCountInString(*in.Name) < 1 || utf8.RuneCountInString(*in.Name) > 256)) { - return in, store.ErrInvalidInput - } - } - delete(fields, "name") - _, in.SetNetwork = fields["network"] - _, in.SetFiles = fields["files"] - _, in.SetEnv = fields["env"] - _, in.SetSetup = fields["setup_commands"] - _, in.SetPackages = fields["packages"] - _, in.SetSkills = fields["skills"] - _, in.SetPlugins = fields["plugins"] - _, in.SetDirectories = fields["capability_directories"] - var setupErr error - in.Initialization, setupErr = decodeEnvironmentSetup(fields) - if setupErr != nil { - return in, setupErr - } - var fileErr error - in.Files, fileErr = decodeInitialFiles(fields["files"]) - if fileErr != nil { - return in, fileErr - } - fields["type"] = json.RawMessage(`"openai_hosted"`) - configuration, err := json.Marshal(fields) - if err != nil { - return in, err - } - environment, err := decodeHostedEnvironment(configuration) - if err != nil { - return in, err - } - in.NetworkAccess = environment.Network.Access - in.AllowedDomains = append([]string{}, environment.Network.AllowedDomains...) - return in, nil -} - -func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { - return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: append([]string{}, t.CapabilityDirectories...), Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: pluginResponse(t.Plugins), Skills: skillResponse(t.Skills)} -} - -func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.EnvironmentTemplateInput, bool) { - raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") - if !ok { - return store.EnvironmentTemplateInput{}, false - } - in, err := decodeTemplateInput(raw) - if writeFieldError(w, err) { - return in, false - } - if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") - return in, false - } - return in, true -} - -// @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. -// @Tags Environment Templates -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param body body v1.EnvironmentTemplateRequest true "Reusable configuration" -// @Success 201 {object} v1.EnvironmentTemplate -// @Failure 400,401,413,500 {object} v1.ErrorResponse -// @Router /agents/environments/templates [post] -func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - in, ok := readTemplateInput(w, r) - if !ok { - return - } - value, err := h.store.CreateEnvironmentTemplate(r.Context(), tenantID(r), in) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusCreated, templateResponse(value)) -} - -// @Summary Retrieve an Environment Template -// @Description Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found response. -// @Tags Environment Templates -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param environment_template_id path string true "Template ID" -// @Success 200 {object} v1.EnvironmentTemplate -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/environments/templates/{environment_template_id} [get] -func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - value, err := h.store.GetEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, templateResponse(value)) -} - -// @Summary Update an Environment Template -// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport and runtime network policy. Empty updates advance updated_at without changing saved fields or confidential contents. Network policy rejections return invalid_request_error with a null param. -// @Tags Environment Templates -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param environment_template_id path string true "Template ID" -// @Param body body v1.EnvironmentTemplateRequest true "Configuration replacements" -// @Success 200 {object} v1.EnvironmentTemplate -// @Failure 400,401,404,413,500 {object} v1.ErrorResponse -// @Router /agents/environments/templates/{environment_template_id} [post] -func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - in, ok := readTemplateInput(w, r) - if !ok { - return - } - value, err := h.store.UpdateEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id"), in) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, templateResponse(value)) -} - -// @Summary Delete an Environment Template -// @Description Deletes the tenant-owned reusable configuration without changing or deleting existing Sessions and their frozen configuration. -// @Tags Environment Templates -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param environment_template_id path string true "Template ID" -// @Success 200 {object} v1.EnvironmentTemplateDeleted -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/environments/templates/{environment_template_id} [delete] -func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - id, err := h.store.DeleteEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.EnvironmentTemplateDeleted{ID: id, Object: "agent.environment.template.deleted", Deleted: true}) -} - -// @Summary List Environment Templates -// @Description Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit 0 is treated as 1 and larger limits as 100. Foreign, missing and malformed cursors return the same not found error. Concurrent-page and exact hosted error behavior remain unverified. -// @Tags Environment Templates -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param after query string false "Previous Template ID" -// @Param limit query integer false "Page size; 0 is treated as 1 and values above 100 as 100" default(20) minimum(0) -// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.EnvironmentTemplateList -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/environments/templates [get] -func (h *Handler) listEnvironmentTemplates(w http.ResponseWriter, r *http.Request) { - options, ok := readClampedPage(w, r) - if !ok { - return - } - page, err := h.store.ListEnvironmentTemplates(r.Context(), tenantID(r), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - response := v1.EnvironmentTemplateList{Object: "list", Data: make([]v1.EnvironmentTemplate, 0, len(page.Templates)), HasMore: page.HasMore} - for _, value := range page.Templates { - response.Data = append(response.Data, templateResponse(value)) - } - if len(response.Data) > 0 { - response.FirstID = &response.Data[0].ID - response.LastID = &response.Data[len(response.Data)-1].ID - } - writeJSON(w, http.StatusOK, response) -} diff --git a/services/agents-api/internal/api/environments.go b/services/agents-api/internal/api/environments.go deleted file mode 100644 index e80c918bf..000000000 --- a/services/agents-api/internal/api/environments.go +++ /dev/null @@ -1,61 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -// @Summary Retrieve an execution Environment -// @Description Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose frozen safe metadata without content; Plugin/Skill entries expose only safe configured installation metadata. Capability-directory discoveries are not added to those arrays. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status does not prove native readiness or process quiescence. -// @Tags Environments -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param environment_id path string true "Environment ID" -// @Success 200 {object} v1.EnvironmentInfo -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/environments/{environment_id} [get] -func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { - environment, err := h.store.GetEnvironment(r.Context(), tenantID(r), chi.URLParam(r, "environment_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - response, err := environmentResponse(environment) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, response) -} - -func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, error) { - configuration, err := storedEnvironment(environment.Configuration) - if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || environment.ID == "" { - return v1.EnvironmentInfo{}, errors.New("unsupported stored environment metadata configuration") - } - switch environment.Status { - case "pending", "connected", "disconnected", "expired", "failed": - default: - return v1.EnvironmentInfo{}, errors.New("unsupported stored environment resource status") - } - files := configuration.Files - if files == nil { - files = []json.RawMessage{} - } - if configuration.Skills == nil { - configuration.Skills = []json.RawMessage{} - } - if configuration.Plugins == nil { - configuration.Plugins = []json.RawMessage{} - } - return v1.EnvironmentInfo{ - ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, - Files: files, Plugins: configuration.Plugins, Skills: configuration.Skills, - }, nil -} diff --git a/services/agents-api/internal/api/function_inputs.go b/services/agents-api/internal/api/function_inputs.go deleted file mode 100644 index 6c877ada8..000000000 --- a/services/agents-api/internal/api/function_inputs.go +++ /dev/null @@ -1,148 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "reflect" - "slices" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type decodedInputEvent struct { - v1.SessionInput - Output json.RawMessage `json:"output,omitempty"` -} - -func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { - var event decodedInputEvent - if err := json.Unmarshal(raw, &event); err != nil { - return event, store.ErrInvalidInput - } - fields := []string{"type"} - switch event.Type { - case "agent.session.input.message": - fields = append(fields, "input") - var messages struct { - Input []struct { - Type json.RawMessage `json:"type"` - Content json.RawMessage `json:"content"` - } `json:"input"` - } - if json.Unmarshal(raw, &messages) != nil { - return event, store.ErrInvalidInput - } - for _, message := range messages.Input { - if len(message.Type) > 0 { - var kind string - if json.Unmarshal(message.Type, &kind) != nil || kind != "message" { - return event, store.ErrInvalidInput - } - } - if err := validateInputContent(message.Content); err != nil { - return event, err - } - } - case "agent.session.input.cancel": - case "agent.session.input.tool_result": - fields = append(fields, "call_id", "turn_id", "success", "error", "output") - default: - return event, store.ErrInvalidInput - } - return event, decodeInputObject(raw, &event, fields...) -} - -func decodeInputObject(raw json.RawMessage, value any, allowed ...string) error { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil || fields == nil { - return store.ErrInvalidInput - } - for field := range fields { - if !slices.Contains(allowed, field) { - return store.ErrInvalidInput - } - } - // Nested members match exactly too; see inexactMember. The raw value is - // valid JSON here, as Unmarshal accepted it. - if inexactMember(raw, reflect.TypeOf(value)) { - return store.ErrInvalidInput - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(value) != nil { - return store.ErrInvalidInput - } - return nil -} - -func functionResultInput(event decodedInputEvent) (store.Input, error) { - if event.CallID == "" || event.TurnID == "" || event.Success == nil { - return store.Input{}, store.ErrInvalidInput - } - if len(event.Error) > 0 && !bytes.Equal(bytes.TrimSpace(event.Error), []byte("null")) { - var message string - if json.Unmarshal(event.Error, &message) != nil { - return store.Input{}, store.ErrInvalidInput - } - } - if err := validateFunctionOutput(event.Output); err != nil { - return store.Input{}, err - } - result, err := json.Marshal(struct { - Success bool `json:"success"` - Error json.RawMessage `json:"error,omitempty"` - Output json.RawMessage `json:"output,omitempty"` - }{*event.Success, event.Error, event.Output}) - if err != nil { - return store.Input{}, err - } - payload, err := json.Marshal(store.FunctionResultInput{TurnID: event.TurnID, CallID: event.CallID, Result: result}) - return store.Input{Kind: "tool_result", Payload: payload}, err -} - -func validateFunctionOutput(raw json.RawMessage) error { - if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return nil - } - var text string - if json.Unmarshal(raw, &text) == nil { - return nil - } - return validateInputContent(raw) -} - -func validateInputContent(raw json.RawMessage) error { - var parts []json.RawMessage - if json.Unmarshal(raw, &parts) != nil { - return store.ErrInvalidInput - } - for _, part := range parts { - var value struct { - Type string `json:"type"` - Text *string `json:"text"` - ImageURL *string `json:"image_url"` - } - if json.Unmarshal(part, &value) != nil { - return store.ErrInvalidInput - } - field := "text" - switch value.Type { - case "input_text": - if value.Text == nil { - return store.ErrInvalidInput - } - case "input_image": - field = "image_url" - if value.ImageURL == nil { - return store.ErrInvalidInput - } - default: - return store.ErrInvalidInput - } - if err := decodeInputObject(part, &value, "type", field); err != nil { - return err - } - } - return nil -} diff --git a/services/agents-api/internal/api/initial_files.go b/services/agents-api/internal/api/initial_files.go deleted file mode 100644 index 852ee018a..000000000 --- a/services/agents-api/internal/api/initial_files.go +++ /dev/null @@ -1,75 +0,0 @@ -package api - -import ( - "encoding/base64" - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { - if len(raw) == 0 { - return nil, nil - } - var entries []json.RawMessage - if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { - return nil, store.ErrInvalidInput - } - files := make([]store.InitialFile, 0, len(entries)) - for _, entry := range entries { - var in struct { - Type string `json:"type"` - Path string `json:"path"` - Data *string `json:"data"` - FileID *string `json:"file_id"` - } - if decodeInputObject(entry, &in, "type", "path", "data", "file_id") != nil { - return nil, store.ErrInvalidInput - } - var fields map[string]json.RawMessage - _ = json.Unmarshal(entry, &fields) - f := store.InitialFile{Type: in.Type, Path: in.Path} - switch in.Type { - case "inline": - if _, exists := fields["file_id"]; exists || in.Data == nil || len(*in.Data) > base64.StdEncoding.EncodedLen(5<<20) { - return nil, store.ErrInvalidInput - } - var err error - f.Data, err = base64.StdEncoding.Strict().DecodeString(*in.Data) - if err != nil { - return nil, store.ErrInvalidInput - } - case "file_id": - if _, exists := fields["data"]; exists || in.FileID == nil { - return nil, store.ErrInvalidInput - } - f.FileID = *in.FileID - default: - return nil, store.ErrInvalidInput - } - files = append(files, f) - } - return files, store.ValidateInitialFiles(files) -} - -func initialFileResponse(files []store.InitialFile) []json.RawMessage { - metadata := make([]store.InitialFileMetadata, 0, len(files)) - for _, file := range files { - m := store.InitialFileMetadata{Type: file.Type, Path: file.Path, FileID: file.FileID} - if file.Type == "inline" { - size := int64(len(file.Data)) - m.SizeBytes = &size - } - metadata = append(metadata, m) - } - return templateFileResponse(metadata) -} - -func templateFileResponse(files []store.InitialFileMetadata) []json.RawMessage { - result := make([]json.RawMessage, 0, len(files)) - for _, file := range files { - body, _ := json.Marshal(file) - result = append(result, body) - } - return result -} diff --git a/services/agents-api/internal/api/inputs.go b/services/agents-api/internal/api/inputs.go deleted file mode 100644 index 4dc98a6cc..000000000 --- a/services/agents-api/internal/api/inputs.go +++ /dev/null @@ -1,146 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "reflect" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -type InputSubmitter interface { - CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) - SubmitInputs(context.Context, string, string, string, []store.Input) ([]store.InputReceipt, error) -} - -type Option func(*Handler) - -// WithExecution enables durable admission when the service owns an execution worker. -func WithExecution(s InputSubmitter) Option { return func(h *Handler) { h.inputs = s } } - -// @Summary Submit Session input events -// @Description An empty events array is a resource-authorized no-op; it creates no execution retry identity, Turn, Item or input receipt. For environment none, atomically accepts text messages, cancellation and function results. Messages steer active work or start a queued Turn. Qualified Codex and Claude SDK workspace profiles accept text and inline PNG/JPEG messages, independently of managed or self_hosted ownership. Under the Session lock, matching retries retain their original target; new active messages append to the current Turn, while idle messages reserve work and wait up to the original five-minute connection/admission deadline. Return 202 only after durable admission, without claiming native application; active messages create no Turn or reservation. Cancellation-only prepared-environment batches use existing durable cancellation admission and return 202 without waiting for native exit; a new cancellation conflicts while a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment batches reuse existing scoped result admission and application receipts without creating a Turn or bypassing a pending reservation. Mixed prepared-environment batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors. New input on a Session whose hosted Environment failed to provision returns the observed 409 conflict_error "the hosted environment failed to provision"; input already waiting when it fails and expired Environments keep the local 409 environment_unavailable. Input the Session cannot accept in its current state, such as a result after cancellation or a batch while earlier input is pending, and a result that differs from the call's saved result return 409 with type and code conflict_error; reusing an Idempotency-Key with a different batch returns the local 409 idempotency_conflict. Inside an owned Session, a result for an unknown call or for a call of another Turn returns 400 invalid_request_error and changes nothing; missing and foreign Sessions return 404. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and executor URL settings. Disconnecting the waiting HTTP request does not cancel retained work or restart its deadline. Retry keys identify the whole ordered batch. Function output accepts text or ordered text/image parts subject to engine support; Claude SDK accepts text results and, on none and qualified workspace profiles, successful inline PNG/JPEG results, preserving ordered content; error images and remote references reject before admission. Native image resizing may change bytes. Runtime image-result support is checked only for image-bearing delivery. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles accept ordered inline PNG/JPEG image messages. Other engines remain text-only; remote image URLs are unsupported. Image references are retained unchanged without service-side downloads. -// @Tags Sessions -// @Accept json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param Idempotency-Key header string false "Retry key, up to 128 bytes" -// @Param session_id path string true "Session ID" -// @Param body body v1.CreateEventsRequest true "Ordered input events" -// @Success 202 -// @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/events [post] -func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObject(w, r) - if !ok { - return - } - var request struct { - Events []json.RawMessage `json:"events"` - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - // An unknown member, including a case variant of events, is rejected before - // decoding; see inexactMember. - if inexactMember(raw, reflect.TypeOf(request)) || decoder.Decode(&request) != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Invalid Session input event request.") - return - } - key := r.Header.Get("Idempotency-Key") - if key == "" { - key = uuid.NewString() - } - if err := store.ValidateInputKey(key); err != nil { - writeStoreError(w, r, err) - return - } - if request.Events != nil && len(request.Events) == 0 { - // Empty batches have no execution identity to reserve or replay. - // Authorize the resource even when no executor is configured. - if _, err := h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")); err != nil { - writeStoreError(w, r, err) - return - } - if !h.auditSessionOperation(w, r, chi.URLParam(r, "session_id"), "send_events") { - return - } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusAccepted) - return - } - if h.inputs == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") - return - } - inputs, err := executionInputs(request.Events) - if err != nil { - writeStoreError(w, r, err) - return - } - sessionID := chi.URLParam(r, "session_id") - if err := h.setEnvironmentInputWriteDeadline(w, r, sessionID); err != nil { - writeStoreError(w, r, err) - return - } - if _, err := h.inputs.SubmitInputs(r.Context(), tenantID(r), sessionID, key, inputs); err != nil { - writeInputError(w, r, err) - return - } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusAccepted) -} - -func executionInputs(events []json.RawMessage) ([]store.Input, error) { - if len(events) == 0 || len(events) > 64 { - return nil, store.ErrInvalidInput - } - inputs := make([]store.Input, 0, len(events)) - for _, raw := range events { - event, err := decodeInputEvent(raw) - if err != nil { - return nil, err - } - switch event.Type { - case "agent.session.input.cancel": - if event.Input != nil { - return nil, store.ErrInvalidInput - } - inputs = append(inputs, store.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)}) - case "agent.session.input.tool_result": - input, err := functionResultInput(event) - if err != nil { - return nil, err - } - inputs = append(inputs, input) - case "agent.session.input.message": - if len(event.Input) == 0 { - return nil, store.ErrInvalidInput - } - for _, message := range event.Input { - if message.Role != "user" || (message.Type != "" && message.Type != "message") || len(message.Content) == 0 { - return nil, store.ErrInvalidInput - } - converted := proto.MessageInput{{}} - for _, content := range message.Content { - converted[0].Content = append(converted[0].Content, proto.InputContent{Type: content.Type, Text: content.Text, ImageURL: content.ImageURL}) - } - if converted.Validate() != nil || converted.ValidateInlineImages() != nil { - return nil, store.ErrInvalidInput - } - } - payload, err := json.Marshal(event) - if err != nil { - return nil, err - } - inputs = append(inputs, store.Input{Kind: "message", Payload: payload}) - default: - return nil, store.ErrInvalidInput - } - } - return inputs, nil -} diff --git a/services/agents-api/internal/api/installation.go b/services/agents-api/internal/api/installation.go deleted file mode 100644 index 726157bbc..000000000 --- a/services/agents-api/internal/api/installation.go +++ /dev/null @@ -1,126 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "path/filepath" - "regexp" - "slices" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// Installation reports Core's installation facts. Core reads them from its -// environment and build; configuration is the installer's settings snapshot. -type Installation struct { - Object string `json:"object" enums:"core.installation"` - // OAC_INSTALLATION_ID; null when Core runs without the sandbox manager. - InstallationID *string `json:"installation_id" extensions:"x-nullable"` - // OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset. - PublicURL *string `json:"public_url" extensions:"x-nullable"` - // public_url followed by /v1; null when public_url is null. - APIBaseURL *string `json:"api_base_url" extensions:"x-nullable"` - // True when public_url names a loopback host, reachable only from the Core host. - LocalOnly bool `json:"local_only"` - // Full source commit Core was built from; null for development builds. - SourceCommit *string `json:"source_commit" extensions:"x-nullable"` - // The installer's settings snapshot (OAC_SETTINGS_FILE); null when the installer did not start Core. - Configuration *InstallationConfiguration `json:"configuration" extensions:"x-nullable"` - AddressBindings store.AddressBindings `json:"address_bindings"` -} - -// InstallationConfiguration says where process settings are changed and what -// the last applied values were. Sensitive values are never included. -type InstallationConfiguration struct { - // Absolute host path of the installation's config.json. - Path string `json:"path"` - // Command that applies config.json changes. - ApplyCommand string `json:"apply_command"` - AppliedAt time.Time `json:"applied_at"` - Settings []InstallationSetting `json:"settings"` -} - -type InstallationSetting struct { - // Dotted config.json key, such as ports.core. - Key string `json:"key"` - // Applied value; always null for a sensitive setting. - Value any `json:"value" extensions:"x-nullable"` - Default any `json:"default" extensions:"x-nullable"` - // Present only for a sensitive setting: whether it has a value. - Configured *bool `json:"configured,omitempty"` - // False for settings fixed at installation. - Changeable bool `json:"changeable"` - Sensitive bool `json:"sensitive"` - // Services that restart when the setting changes. - Restarts []string `json:"restarts"` -} - -var installationSettingKey = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`) - -const maxInstallationSettings = 64 << 10 - -// ParseInstallationConfiguration validates the installer's settings snapshot. -// A sensitive setting that carries a value is rejected, so the snapshot cannot -// leak a secret through this read. -func ParseInstallationConfiguration(raw []byte) (*InstallationConfiguration, error) { - invalid := errors.New("OAC_SETTINGS_FILE must contain the installer's settings snapshot") - if len(raw) > maxInstallationSettings { - return nil, invalid - } - var value InstallationConfiguration - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&value) != nil || decoder.Decode(new(any)) != io.EOF { - return nil, invalid - } - if !filepath.IsAbs(value.Path) || value.ApplyCommand == "" || len(value.ApplyCommand) > 4096 || value.AppliedAt.IsZero() || value.Settings == nil { - return nil, invalid - } - seen := make(map[string]bool, len(value.Settings)) - for _, setting := range value.Settings { - if !installationSettingKey.MatchString(setting.Key) || seen[setting.Key] || setting.Restarts == nil || - setting.Sensitive != (setting.Configured != nil) || (setting.Sensitive && (setting.Value != nil || setting.Default != nil)) { - return nil, invalid - } - for _, service := range setting.Restarts { - if !slices.Contains([]string{"core", "web", "database"}, service) { - return nil, invalid - } - } - seen[setting.Key] = true - } - return &value, nil -} - -// WithInstallation serves GET /core/v1/installation. bindings counts what is -// bound to the current public URL. -func WithInstallation(value Installation, bindings func(context.Context) (store.AddressBindings, error)) Option { - return func(h *Handler) { - value.Object = "core.installation" - h.installation, h.installationBindings = &value, bindings - } -} - -// @Summary Retrieve installation facts and process settings -// @Description Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Success 200 {object} api.Installation -// @Failure 401,500 {object} CoreErrorResponse -// @Router /core/v1/installation [get] -func (h *Handler) getInstallation(w http.ResponseWriter, r *http.Request) { - bindings, err := h.installationBindings(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - value := *h.installation - value.AddressBindings = bindings - writeJSON(w, http.StatusOK, value) -} diff --git a/services/agents-api/internal/api/model_provider_fixture_test.go b/services/agents-api/internal/api/model_provider_fixture_test.go deleted file mode 100644 index eb09d5e76..000000000 --- a/services/agents-api/internal/api/model_provider_fixture_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package api - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// Hosted and self-hosted Sessions must freeze a model provider. Tests that -// exercise other behavior supply these fixtures instead of relaxing that check. - -// fixtureModelProvider returns a valid bundle for the harness. -func fixtureModelProvider(harness string) *v1.ModelProviderInput { - if harness == "codex" { - return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} - } - return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} -} - -// withFixtureDeploymentProvider configures a deployment default for every harness. -func withFixtureDeploymentProvider() Option { - return WithModelProviderDefaults(func(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: fixtureModelProvider(harness), Revision: uuid.New()}, nil - }) -} - -// fixtureSessionProvider is a top-level Session request member for Codex. -const fixtureSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.fixture.example/v1","api_key":"fixture-model-key"}}` - -// fixtureAnthropicSessionProvider is the Claude Code and MiniMax Code equivalent. -const fixtureAnthropicSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"anthropic","base_url":"https://model.fixture.example/anthropic","api_key":"fixture-model-key","context_window":200000,"max_output_tokens":8000}}` diff --git a/services/agents-api/internal/api/project_api_keys.go b/services/agents-api/internal/api/project_api_keys.go deleted file mode 100644 index 84b329876..000000000 --- a/services/agents-api/internal/api/project_api_keys.go +++ /dev/null @@ -1,290 +0,0 @@ -package api - -import ( - "context" - "encoding/hex" - "net/http" - "strconv" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -type ProjectAPIKeyStore interface { - CreateProject(context.Context, string, string) (store.Project, error) - GetProject(context.Context, string) (store.ProjectBinding, error) - ListProjects(context.Context, string, int, bool) (store.ProjectPage, error) - RenameProject(context.Context, string, string) (store.Project, error) - ArchiveProject(context.Context, string) (store.Project, error) - CreateProjectAPIKey(context.Context, string, string, string) (store.IssuedProjectAPIKey, error) - ListProjectAPIKeys(context.Context, string, string, int, bool) (store.ProjectAPIKeyPage, error) - RevokeProjectAPIKey(context.Context, string, string) error - ResolveProjectAPIKey(context.Context, string) (store.ProjectAPIKeyBinding, error) -} -type ProjectRequest struct { - Name string `json:"name"` -} -type ProjectAPIKeyRequest struct { - Name string `json:"name"` -} - -func WithProjectAPIKeys(s ProjectAPIKeyStore, auth *DeploymentAuthenticator) Option { - return func(h *Handler) { - h.projectKeys = s - if auth != nil { - h.deploymentAuth = auth - } - } -} -func (h *Handler) registerProjectAPIKeyRoutes(r chi.Router) { - if h.projectKeys == nil { - return - } - r.Get("/projects", h.listProjects) - r.Post("/projects", h.createProject) - r.Post("/projects/{project_id}", h.renameProject) - r.Post("/projects/{project_id}/archive", h.archiveProject) - r.Get("/projects/{project_id}/keys", h.listProjectAPIKeys) - r.Post("/projects/{project_id}/keys", h.createProjectAPIKey) - r.Delete("/projects/{project_id}/keys/{key_id}", h.revokeProjectAPIKey) -} -func (h *Handler) resolveAdminProject(ctx context.Context, id string) (store.ProjectBinding, error) { - return h.projectKeys.GetProject(ctx, id) -} -func (h *Handler) listAdminProjects(ctx context.Context, after string, limit int, ascending bool) (store.ProjectPage, error) { - return h.projectKeys.ListProjects(ctx, after, limit, ascending) -} -func (h *Handler) adminProjectScope(w http.ResponseWriter, r *http.Request) (store.ProjectBinding, bool) { - p, err := h.resolveAdminProject(r.Context(), chi.URLParam(r, "project_id")) - if err != nil { - writeStoreError(w, r, err) - return store.ProjectBinding{}, false - } - setAdminAuditSource(r, p.Project.ID) - return p, true -} -func setAdminAuditSource(r *http.Request, projectID string) { - digest, _ := projectBearerDigest(r) - requestID, _ := log.RequestIDFromContext(r.Context()) - source := adminaudit.Source{CredentialID: hex.EncodeToString(digest[:])[:8], ActorLabel: r.Header.Get("X-Core-Console-Actor"), RequestID: requestID, TraceID: requestID, ProjectID: projectID} - if carrier, ok := log.TraceFromContext(r.Context()); ok { - source.TraceID = carrier.Trace.String() - } - *r = *r.WithContext(adminaudit.WithSource(r.Context(), source)) -} -func adminCatalogPage(r *http.Request) (string, int, bool, error) { - values := r.URL.Query() - limit := 20 - ascending := false - for _, name := range []string{"after", "limit", "order"} { - if len(values[name]) > 1 { - return "", 0, false, store.ErrInvalidInput - } - } - if raw, ok := values["limit"]; ok { - n, err := strconv.Atoi(raw[0]) - if err != nil || n < 1 || n > 100 { - return "", 0, false, store.ErrInvalidInput - } - limit = n - } - if raw, ok := values["order"]; ok { - if raw[0] != "asc" && raw[0] != "desc" { - return "", 0, false, store.ErrInvalidInput - } - ascending = raw[0] == "asc" - } - return values.Get("after"), limit, ascending, nil -} - -// @Summary List Projects and active key counts -// @Tags Administrator Projects -// @Produce json -// @Security DeploymentAdminAuth -// @Param after query string false "Project ID cursor" -// @Param limit query int false "Page size (1-100)" -// @Param order query string false "asc or desc by Project ID" -// @Success 200 {object} store.ProjectPage -// @Failure 400,401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects [get] -func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { - after, limit, ascending, err := adminCatalogPage(r) - if err != nil { - writeStoreError(w, r, err) - return - } - page, err := h.listAdminProjects(r.Context(), after, limit, ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 200, page) -} - -// @Summary Create an empty independent Project -// @Tags Administrator Projects -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param body body api.ProjectRequest true "Project display name" -// @Success 201 {object} store.Project -// @Failure 400,401,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects [post] -func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONBodyLimit(w, r, 4096, "Project request is too large.") - if !ok { - return - } - var input ProjectRequest - if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - id := uuid.NewString() - setAdminAuditSource(r, id) - p, err := h.projectKeys.CreateProject(r.Context(), id, input.Name) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 201, p) -} - -// @Summary Rename a Project -// @Tags Administrator Projects -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param body body api.ProjectRequest true "Project display name" -// @Success 200 {object} store.Project -// @Failure 400,401,404,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id} [post] -func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - raw, ok := readJSONBodyLimit(w, r, 4096, "Project request is too large.") - if !ok { - return - } - var input ProjectRequest - if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - p, err := h.projectKeys.RenameProject(r.Context(), binding.Project.ID, input.Name) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 200, p) -} - -// @Summary Archive a Project and revoke all its keys while retaining assets -// @Tags Administrator Projects -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Success 200 {object} store.Project -// @Failure 401,404,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/archive [post] -func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - p, err := h.projectKeys.ArchiveProject(r.Context(), binding.Project.ID) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 200, p) -} - -// @Summary List safe key metadata for a Project -// @Tags Administrator Projects -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param after query string false "Key ID cursor" -// @Param limit query int false "Page size (1-100)" -// @Param order query string false "asc or desc by key ID" -// @Success 200 {object} store.ProjectAPIKeyPage -// @Failure 400,401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/keys [get] -func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - after, limit, ascending, err := adminCatalogPage(r) - if err != nil { - writeStoreError(w, r, err) - return - } - page, err := h.projectKeys.ListProjectAPIKeys(r.Context(), binding.Project.ID, after, limit, ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 200, page) -} - -// @Summary Issue an independent secret in an existing Project -// @Tags Administrator Projects -// @Accept json -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param body body api.ProjectAPIKeyRequest true "Key display name" -// @Success 201 {object} store.IssuedProjectAPIKey -// @Failure 400,401,404,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/keys [post] -func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - raw, ok := readJSONBodyLimit(w, r, 4096, "API key request is too large.") - if !ok { - return - } - var input ProjectAPIKeyRequest - if decodeInputObject(raw, &input, "name") != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - key, err := h.projectKeys.CreateProjectAPIKey(r.Context(), binding.Project.ID, uuid.NewString(), input.Name) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 201, key) -} - -// @Summary Revoke one Project key while retaining shared assets -// @Tags Administrator Projects -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project UUID" -// @Param key_id path string true "API key UUID" -// @Success 200 {object} api.SandboxMutationResponse -// @Failure 401,404,409,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/keys/{key_id} [delete] -func (h *Handler) revokeProjectAPIKey(w http.ResponseWriter, r *http.Request) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } - id := chi.URLParam(r, "key_id") - if err := h.projectKeys.RevokeProjectAPIKey(r.Context(), binding.Project.ID, id); err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, 200, SandboxMutationResponse{ID: id, Deleted: true}) -} diff --git a/services/agents-api/internal/api/project_api_keys_test.go b/services/agents-api/internal/api/project_api_keys_test.go deleted file mode 100644 index 046bed20c..000000000 --- a/services/agents-api/internal/api/project_api_keys_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type projectKeyStoreFixture struct { - ProjectAPIKeyStore - binding store.ProjectAPIKeyBinding - project store.ProjectBinding - resolve error - lookups int -} - -func (s *projectKeyStoreFixture) GetProject(_ context.Context, id string) (store.ProjectBinding, error) { - if s.project.Project.ID == id { - return s.project, nil - } - return store.ProjectBinding{}, store.ErrNotFound -} - -func (s *projectKeyStoreFixture) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - s.lookups++ - if s.resolve != nil { - return store.ProjectAPIKeyBinding{}, s.resolve - } - if digest != device.HashCredential("issued-project-key") { - return store.ProjectAPIKeyBinding{}, store.ErrNotFound - } - return s.binding, nil -} -func projectKeyHTTP(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r.Header.Set("Authorization", "Bearer "+token) - r.Header.Set("OpenAI-Beta", "agents=v1") - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - return w -} -func TestAdminCredentialNeverAuthenticatesPublicAPI(t *testing.T) { - key := callerBinding() - auth, err := NewAuthenticator([]APIKey{key}) - if err != nil { - t.Fatal(err) - } - admin, err := NewDeploymentAuthenticator([]string{key.TokenSHA256}) - if err != nil { - t.Fatal(err) - } - h := &Handler{auth: auth, deploymentAuth: admin} - r := httptest.NewRequest("GET", "/v1/files", nil) - r.Header.Set("Authorization", "Bearer caller") - _, _, ok, err := h.resolveCaller(r) - if ok || err != nil { - t.Fatal("administrator authenticated on public API") - } -} -func TestDatabaseResolverControlsAuthentication(t *testing.T) { - p := callerBinding() - fixture, _ := NewAuthenticator([]APIKey{p}) - binding, _ := fixture.keys.ResolveProjectAPIKey(t.Context(), p.TokenSHA256) - keys := &projectKeyStoreFixture{binding: binding} - auth, _ := NewDatabaseAuthenticator(keys) - h := &Handler{auth: auth} - r := httptest.NewRequest("GET", "/v1/files", nil) - r.Header.Set("Authorization", "Bearer issued-project-key") - got, _, ok, err := h.resolveCaller(r) - if err != nil || !ok || got != binding.Principal { - t.Fatal("database key rejected") - } - keys.resolve = store.ErrNotFound - _, _, ok, err = h.resolveCaller(r) - if err != nil || ok { - t.Fatal("revoked database key authenticated") - } - keys.resolve = errors.New("database unavailable") - w := projectKeyHTTP(h.authenticateCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unavailable auth reached handler") }), true), "GET", "/v1/files", "issued-project-key", "") - if w.Code != 503 { - t.Fatal("database failure did not fail closed") - } -} - -type separationFixture struct { - digests []string - err error -} - -func (s *separationFixture) ValidateProjectKeySeparation(_ context.Context, digests []string) error { - s.digests = digests - return s.err -} -func TestAdministratorCredentialSeparation(t *testing.T) { - s := &separationFixture{} - if err := ValidateCredentialSeparation(t.Context(), nil, s); err == nil { - t.Fatal("missing administrator accepted") - } - digest := device.HashCredential("admin") - admin, _ := NewDeploymentAuthenticator([]string{digest}) - if err := ValidateCredentialSeparation(t.Context(), admin, s); err != nil || len(s.digests) != 1 || s.digests[0] != digest { - t.Fatal("administrator digest was not checked against persisted keys", err) - } - s.err = errors.New("credential overlap") - if err := ValidateCredentialSeparation(t.Context(), admin, s); !errors.Is(err, s.err) { - t.Fatal("persisted credential collision accepted") - } -} -func TestAdminCatalogPageLimits(t *testing.T) { - for _, query := range []string{"limit=101", "limit=0", "limit=bad", "limit=1&limit=2", "order=sideways", "order=asc&order=desc", "after=a&after=b"} { - if _, _, _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?"+query, nil)); err == nil { - t.Errorf("invalid page accepted: %s", query) - } - } - _, limit, ascending, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?limit=100&order=asc", nil)) - if err != nil || limit != 100 || !ascending { - t.Fatal("valid maximum page rejected", err) - } -} diff --git a/services/agents-api/internal/api/runtime_history.go b/services/agents-api/internal/api/runtime_history.go deleted file mode 100644 index 7cf7d39a6..000000000 --- a/services/agents-api/internal/api/runtime_history.go +++ /dev/null @@ -1,186 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "strconv" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/go-chi/chi/v5" -) - -const ( - defaultRuntimeHistoryPoints = 120 - runtimeHistoryRequestBudget = 15 * time.Second -) - -type RuntimeHistoryService interface { - Capabilities() runtimehistory.Capabilities - QuerySession(context.Context, string, string, runtimehistory.Range) (runtimehistory.Response, error) -} - -func WithRuntimeHistory(service RuntimeHistoryService) Option { - return func(h *Handler) { h.runtimeHistory = service } -} - -// getRuntimeHistory serves the administrator per-Session history read. -func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { - if h.runtimeHistory == nil { - writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is not configured on this service.") - return - } - capabilities := h.runtimeHistory.Capabilities() - if capabilities.Validate() != nil || !capabilities.Durable() { - writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is not configured on this service.") - return - } - requested, ok := readRuntimeHistoryRange(w, r, capabilities, time.Now().UTC()) - if !ok { - return - } - expectedTenantID := tenantID(r) - expectedSessionID := chi.URLParam(r, "session_id") - ctx, cancel := context.WithTimeout(r.Context(), runtimeHistoryRequestBudget) - defer cancel() - value, err := h.runtimeHistory.QuerySession(ctx, expectedTenantID, expectedSessionID, requested) - if err != nil { - switch { - case errors.Is(err, runtimehistory.ErrInvalidRange): - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") - case errors.Is(err, runtimehistory.ErrUnsupported): - writeError(w, http.StatusConflict, "runtime_history_unsupported", "Runtime history is not supported for this Session.") - case errors.Is(err, runtimehistory.ErrUnavailable), errors.Is(err, runtimehistory.ErrInvalidResult), errors.Is(err, context.DeadlineExceeded): - log.Ctx(r.Context()).Warn("Runtime history query unavailable") - writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is temporarily unavailable.") - default: - writeStoreError(w, r, err) - } - return - } - response, err := runtimeHistoryResponse(value, expectedTenantID, expectedSessionID, requested) - if err != nil { - log.Ctx(r.Context()).Error("Runtime history response validation failed") - writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is temporarily unavailable.") - return - } - writeJSON(w, http.StatusOK, response) -} - -func readRuntimeHistoryRange(w http.ResponseWriter, r *http.Request, capabilities runtimehistory.Capabilities, now time.Time) (runtimehistory.Range, bool) { - query := r.URL.Query() - for key, values := range query { - if key != "start" && key != "end" && key != "max_points" || len(values) != 1 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime history accepts one start, end and optional max_points value.") - return runtimehistory.Range{}, false - } - } - start, startErr := strconv.ParseInt(query.Get("start"), 10, 64) - end, endErr := strconv.ParseInt(query.Get("end"), 10, 64) - points := defaultRuntimeHistoryPoints - if capabilities.MaximumPoints < points { - points = capabilities.MaximumPoints - } - if raw := query.Get("max_points"); raw != "" { - var err error - points, err = strconv.Atoi(raw) - if err != nil || points < 2 || points > capabilities.MaximumPoints { - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") - return runtimehistory.Range{}, false - } - } - if startErr != nil || endErr != nil || start < 0 || end <= start || end > now.Unix()+1 || end-start > int64(capabilities.MaximumRange/time.Second) { - writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") - return runtimehistory.Range{}, false - } - return runtimehistory.Range{Start: time.Unix(start, 0).UTC(), End: time.Unix(end, 0).UTC(), MaxPoints: points}, true -} - -func runtimeHistoryResponse(value runtimehistory.Response, expectedTenantID, expectedSessionID string, expectedRange runtimehistory.Range) (v1.RuntimeHistory, error) { - if err := value.Validate(time.Now().UTC()); err != nil || !value.Durable() || - value.TenantID != expectedTenantID || value.SessionID != expectedSessionID || - !value.Requested.Start.Equal(expectedRange.Start) || !value.Requested.End.Equal(expectedRange.End) || value.Requested.MaxPoints != expectedRange.MaxPoints { - return v1.RuntimeHistory{}, runtimehistory.ErrInvalidResult - } - retainedStart := value.Requested.Start - configuredStart := value.GeneratedAt.Add(-value.Retention) - if configuredStart.After(retainedStart) { - retainedStart = configuredStart - } - if value.RetainedFrom != nil && value.RetainedFrom.After(retainedStart) { - retainedStart = *value.RetainedFrom - } - if retainedStart.After(value.Requested.End) { - retainedStart = value.Requested.End - } - coverage := v1.RuntimeHistoryCoverage{RetainedStart: retainedStart.Unix(), Buckets: make([]v1.RuntimeHistoryCoveragePoint, 0, len(value.Coverage))} - if retainedStart.Before(value.Requested.End) { - duration := value.Requested.End.Sub(retainedStart) - coverage.ExpectedSampleCount = int64(duration / value.SampleInterval) - if duration%value.SampleInterval != 0 { - coverage.ExpectedSampleCount++ - } - } - for _, point := range value.Coverage { - converted := v1.RuntimeHistoryCoveragePoint{ - Start: point.Start.Unix(), End: point.End.Unix(), ObservationCount: point.ObservationCount, - ObservedCount: point.ObservedCount, UnavailableCount: point.UnavailableCount, - } - if point.ObservationCount > 0 { - first, last := point.FirstObservedAt.Unix(), point.LastObservedAt.Unix() - converted.FirstObservedAt, converted.LastObservedAt = &first, &last - if coverage.FirstSampleAt == nil || first < *coverage.FirstSampleAt { - value := first - coverage.FirstSampleAt = &value - } - if coverage.LastSampleAt == nil || last > *coverage.LastSampleAt { - value := last - coverage.LastSampleAt = &value - } - } - coverage.SampleCount += int64(point.ObservationCount) - coverage.Buckets = append(coverage.Buckets, converted) - } - response := v1.RuntimeHistory{ - Object: "agent.runtime_history", Source: "durable", SessionID: value.SessionID, - RequestedRange: v1.RuntimeHistoryRange{Start: value.Requested.Start.Unix(), End: value.Requested.End.Unix()}, - ResolutionSeconds: int64(value.Resolution / time.Second), GeneratedAt: value.GeneratedAt.Unix(), Coverage: coverage, - Series: make([]v1.RuntimeHistorySeries, 0, len(value.Series)), - TokenUsage: make([]v1.RuntimeHistoryTokenUsagePoint, 0, len(value.TokenUsage)), - } - for _, point := range value.TokenUsage { - response.TokenUsage = append(response.TokenUsage, v1.RuntimeHistoryTokenUsagePoint{ - Start: point.Start.Unix(), End: point.End.Unix(), SampledAt: point.SampledAt.Unix(), - InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, - }) - } - for _, series := range value.Series { - converted := v1.RuntimeHistorySeries{ - EnvironmentID: series.EnvironmentID, AllocationID: series.AllocationID, - StartedAt: v1.RuntimeHistoryTime{Seconds: series.StartedAt.Unix(), Nanoseconds: series.StartedAt.Nanosecond()}, ProviderType: series.ProviderType, - Points: make([]v1.RuntimeHistoryPoint, 0, len(series.Points)), - } - for _, point := range series.Points { - item := v1.RuntimeHistoryPoint{ - Start: point.Start.Unix(), End: point.End.Unix(), ObservationCount: point.ObservationCount, - ObservedCount: point.ObservedCount, UnavailableCount: point.UnavailableCount, - } - if point.ObservationCount > 0 { - first, last := point.FirstObservedAt.Unix(), point.LastObservedAt.Unix() - item.FirstObservedAt, item.LastObservedAt = &first, &last - } - if point.CPUContributorCount > 0 { - item.CPU = &v1.RuntimeHistoryCPU{ContributorCount: point.CPUContributorCount, UtilizationRatio: point.CPUUtilizationRatio, CapacityCores: point.CPUCapacityCores} - } - if point.MemoryContributorCount > 0 { - item.Memory = &v1.RuntimeHistoryMemory{ContributorCount: point.MemoryContributorCount, UsageBytes: point.MemoryUsageBytes, LimitBytes: point.MemoryLimitBytes} - } - converted.Points = append(converted.Points, item) - } - response.Series = append(response.Series, converted) - } - return response, nil -} diff --git a/services/agents-api/internal/api/runtime_history_test.go b/services/agents-api/internal/api/runtime_history_test.go deleted file mode 100644 index fa7aa8240..000000000 --- a/services/agents-api/internal/api/runtime_history_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type runtimeHistoryFixture struct { - capabilities runtimehistory.Capabilities - response runtimehistory.Response - err error - tenant string - session string - requested runtimehistory.Range - calls int -} - -func (f *runtimeHistoryFixture) Capabilities() runtimehistory.Capabilities { return f.capabilities } - -func (f *runtimeHistoryFixture) QuerySession(_ context.Context, tenant, session string, requested runtimehistory.Range) (runtimehistory.Response, error) { - f.calls++ - f.tenant, f.session, f.requested = tenant, session, requested - return f.response, f.err -} - -func historyCapabilities(mode runtimehistory.CollectionMode) runtimehistory.Capabilities { - value := runtimehistory.Capabilities{ - CollectionMode: mode, Retention: 7 * 24 * time.Hour, MinimumStep: 30 * time.Second, - MaximumRange: 24 * time.Hour, MaximumPoints: 1_000, MaximumSeries: 64, MaximumTotalPoints: 10_000, - Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, - } - if mode == runtimehistory.CollectionPeriodic { - value.SampleInterval = 30 * time.Second - } - return value -} - -func TestRuntimeHistoryRequiresQualifiedPeriodicCollection(t *testing.T) { - service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionOnRead)} - handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") - if response.Code != http.StatusServiceUnavailable || service.calls != 0 { - t.Fatalf("on-read history reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) - } -} - -func TestRuntimeHistoryFailsClosedForMalformedCapabilities(t *testing.T) { - service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - service.capabilities.Retention = 0 - handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") - if response.Code != http.StatusServiceUnavailable || service.calls != 0 { - t.Fatalf("malformed capabilities reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) - } -} - -func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - start := now.Add(-time.Hour) - sessionID, environmentID, allocationID := uuid.NewString(), uuid.NewString(), uuid.NewString() - scope := runtimehistory.Scope{SessionID: sessionID, EnvironmentID: environmentID} - zeroRatio := float64(0) - capacity := 2.0 - zeroMemory := uint64(0) - limit := uint64(2048) - coveragePoint := runtimehistory.CoveragePoint{ - Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), - ObservationCount: 1, ObservedCount: 1, - } - resourcePoint := runtimehistory.Point{ - Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), - ObservationCount: 1, ObservedCount: 1, CPUContributorCount: 1, MemoryContributorCount: 1, - CPUUtilizationRatio: &zeroRatio, CPUCapacityCores: &capacity, MemoryUsageBytes: &zeroMemory, MemoryLimitBytes: &limit, - } - service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) - scope.TenantID = tenant - service.response = runtimehistory.Response{ - Capabilities: service.capabilities, Scope: scope, - Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, Resolution: time.Minute, GeneratedAt: now, RetainedFrom: &start, - Coverage: []runtimehistory.CoveragePoint{coveragePoint}, - Series: []runtimehistory.Series{{Scope: scope, AllocationID: allocationID, StartedAt: start.Add(-time.Minute), ProviderType: "docker", Points: []runtimehistory.Point{resourcePoint}}}, - TokenUsage: []runtimehistory.TokenUsagePoint{{Start: start, End: start.Add(time.Minute), SampledAt: start.Add(10 * time.Second), InputTokens: 120, OutputTokens: 30}}, - } - response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history?start="+timeString(start)+"&end="+timeString(now)+"&max_points=60") - if response.Code != http.StatusOK { - t.Fatalf("history returned %d: %s", response.Code, response.Body) - } - var value v1.RuntimeHistory - if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history" || value.Source != "durable" || value.SessionID != sessionID || value.ResolutionSeconds != 60 || value.Coverage.SampleCount != 1 || value.Coverage.ExpectedSampleCount != 120 || len(value.Coverage.Buckets) != 1 || len(value.Series) != 1 || len(value.Series[0].Points) != 1 || len(value.TokenUsage) != 1 || value.TokenUsage[0].InputTokens != 120 || value.TokenUsage[0].OutputTokens != 30 { - t.Fatalf("invalid history response: %s", response.Body) - } - point := value.Series[0].Points[0] - if point.CPU == nil || point.CPU.UtilizationRatio == nil || *point.CPU.UtilizationRatio != 0 || point.Memory == nil || point.Memory.UsageBytes == nil || *point.Memory.UsageBytes != 0 { - t.Fatalf("observed zero was lost: %+v", point) - } - if service.calls != 1 || service.tenant != tenant || service.session != sessionID || !service.requested.Start.Equal(start) || !service.requested.End.Equal(now) || service.requested.MaxPoints != 60 { - t.Fatalf("history authority/range mismatch: %+v", service) - } -} - -func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { - service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - sessionID := uuid.NewString() - for _, query := range []string{ - "", "?start=1", "?start=2&end=1", "?start=x&end=2", "?start=1&end=2&provider=docker", "?start=1&start=1&end=2", "?start=1&end=2&max_points=x", "?start=1&end=2&max_points=1", "?start=1&end=2&max_points=1001", "?start=1&end=90002", "?start=1&end=9223372036854775807", - } { - response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history"+query) - if response.Code != http.StatusBadRequest { - t.Fatalf("unsafe query %q returned %d: %s", query, response.Code, response.Body) - } - } - if service.calls != 0 { - t.Fatalf("unsafe query reached history service %d times", service.calls) - } - - now := time.Now().UTC().Truncate(time.Second) - path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(now.Add(-time.Hour)) + "&end=" + timeString(now) - for _, tc := range []struct { - err error - code int - }{ - {runtimehistory.ErrInvalidRange, http.StatusBadRequest}, - {runtimehistory.ErrUnsupported, http.StatusConflict}, - {runtimehistory.ErrUnavailable, http.StatusServiceUnavailable}, - {runtimehistory.ErrInvalidResult, http.StatusServiceUnavailable}, - {store.ErrNotFound, http.StatusNotFound}, - } { - service.err = tc.err - response := runtimeObservationRequest(handler, path) - if response.Code != tc.code { - t.Fatalf("history error %v returned %d: %s", tc.err, response.Code, response.Body) - } - } - service.err = nil - service.response = runtimehistory.Response{} - response := runtimeObservationRequest(handler, path) - if response.Code != http.StatusServiceUnavailable { - t.Fatalf("malformed history response returned %d: %s", response.Code, response.Body) - } -} - -func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - start := now.Add(-time.Hour) - sessionID := uuid.NewString() - service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) - base := runtimehistory.Response{ - Capabilities: service.capabilities, - Scope: runtimehistory.Scope{ - TenantID: tenant, SessionID: sessionID, EnvironmentID: uuid.NewString(), - }, - Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, - Resolution: time.Minute, GeneratedAt: now, - } - path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(start) + "&end=" + timeString(now) + "&max_points=60" - - for name, mutate := range map[string]func(*runtimehistory.Response){ - "tenant": func(value *runtimehistory.Response) { value.TenantID = uuid.NewString() }, - "Session": func(value *runtimehistory.Response) { value.SessionID = uuid.NewString() }, - "range": func(value *runtimehistory.Response) { - value.Requested.Start = value.Requested.Start.Add(30 * time.Second) - }, - } { - t.Run(name, func(t *testing.T) { - service.response = base - mutate(&service.response) - response := runtimeObservationRequest(handler, path) - if response.Code != http.StatusServiceUnavailable { - t.Fatalf("mismatched %s response returned %d: %s", name, response.Code, response.Body) - } - }) - } -} - -func TestRuntimeHistoryDefaultPointBudgetRespectsCapabilities(t *testing.T) { - capabilities := historyCapabilities(runtimehistory.CollectionPeriodic) - capabilities.MaximumPoints = 60 - service := &runtimeHistoryFixture{capabilities: capabilities, err: runtimehistory.ErrUnavailable} - handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - now := time.Now().UTC().Truncate(time.Second) - response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start="+timeString(now.Add(-time.Hour))+"&end="+timeString(now)) - if response.Code != http.StatusServiceUnavailable || service.calls != 1 || service.requested.MaxPoints != 60 { - t.Fatalf("default point budget ignored capabilities: status=%d calls=%d requested=%+v", response.Code, service.calls, service.requested) - } -} - -func TestRuntimeHistoryExpectedCoverageUsesOverflowSafeCeiling(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - start := now.Add(-time.Hour) - huge := (time.Duration(1<<63-1) / time.Second) * time.Second - capabilities := historyCapabilities(runtimehistory.CollectionPeriodic) - capabilities.SampleInterval = huge - capabilities.Retention = huge - capabilities.MaximumRange = time.Hour - value := runtimehistory.Response{ - Capabilities: capabilities, - Scope: runtimehistory.Scope{ - TenantID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), - }, - Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, - Resolution: time.Minute, - GeneratedAt: now, - } - response, err := runtimeHistoryResponse(value, value.TenantID, value.SessionID, value.Requested) - if err != nil || response.Coverage.ExpectedSampleCount != 1 { - t.Fatalf("unsafe expected sample ceiling: count=%d err=%v", response.Coverage.ExpectedSampleCount, err) - } -} - -func timeString(value time.Time) string { return fmt.Sprintf("%d", value.Unix()) } diff --git a/services/agents-api/internal/api/sandbox_deployment_setup.go b/services/agents-api/internal/api/sandbox_deployment_setup.go deleted file mode 100644 index 5c06a104b..000000000 --- a/services/agents-api/internal/api/sandbox_deployment_setup.go +++ /dev/null @@ -1,241 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "strconv" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// SandboxE2BInput is write-only provider configuration. Safe responses use the -// store's separate deployment view and never serialize this request. -type SandboxE2BInput struct { - APIKey *string `json:"api_key,omitempty"` - APIURL string `json:"api_url,omitempty"` - Domain string `json:"domain,omitempty"` - Template string `json:"template"` -} - -type SandboxDeploymentInput struct { - ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` - // Per-sandbox limits, required for Docker and microsandbox. E2B may omit - // them; Core then uses the validated template build's cpus and memory_mib. - Resources sandbox.Resources `json:"resources"` - Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` - Provider string `json:"provider"` - E2B *SandboxE2BInput `json:"e2b,omitempty"` -} - -type SandboxDeploymentChangeInput struct { - SandboxDeploymentInput -} - -func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest { - input := store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} - if v.E2B != nil { - input.E2B = &sandbox.E2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain} - if v.E2B.APIKey != nil { - input.E2B.APIKey = *v.E2B.APIKey - input.E2B.ReplaceCredential = true - } - } - return input -} - -// rejectCoreURL names the retired member instead of reporting a generic unknown -// member: Core derives core_url from the installation public URL. -func rejectCoreURL(w http.ResponseWriter, raw json.RawMessage) bool { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return false - } - if _, present := fields["core_url"]; !present { - return false - } - writeError(w, http.StatusBadRequest, "invalid_request_error", "core_url is derived from the installation public URL (public_url in config.json, OAC_PUBLIC_URL for Core) and cannot be set here. Remove it.", "core_url") - return true -} - -func WithSandboxDeploymentSetup(initialize func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error)) Option { - return func(h *Handler) { h.sandboxSetup = initialize } -} - -func WithSandboxDeploymentChanges( - update func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error), - reset func(context.Context, store.SandboxResetRequest) (store.RuntimeDeploymentView, error), - cancel func(context.Context, uint64) (store.RuntimeDeploymentView, error), -) Option { - return func(h *Handler) { h.sandboxUpdate = update; h.sandboxReset = reset; h.sandboxResetCancel = cancel } -} - -// @Summary Initialize the deployment sandbox provider -// @Description Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; differing selections and file-managed deployments reject. This does not create compute or execute work. -// @Tags Sandbox Manager -// @Produce json -// @Security DeploymentAdminAuth -// @Accept json -// @Param body body api.SandboxDeploymentInput true "Deployment selection" -// @Success 200 {object} store.RuntimeDeploymentView -// @Failure 400,401,409,500,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/deployment [post] -func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONBody(w, r) - if !ok { - return - } - if rejectCoreURL(w, raw) { - return - } - var input SandboxDeploymentInput - if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if h.sandboxSetup == nil { - writeStoreError(w, r, store.ErrSandboxDeploymentConflict) - return - } - result, err := h.sandboxSetup(r.Context(), input.request()) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -// @Summary Change the sandbox deployment configuration -// @Description Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. -// @Tags Sandbox Manager -// @Produce json -// @Security DeploymentAdminAuth -// @Accept json -// @Param body body api.SandboxDeploymentChangeInput true "Replacement deployment selection" -// @Success 200 {object} store.RuntimeDeploymentView -// @Failure 400,401,409,500,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/deployment [put] -func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONBody(w, r) - if !ok { - return - } - if rejectCoreURL(w, raw) { - return - } - var input SandboxDeploymentChangeInput - if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if h.sandboxUpdate == nil { - writeStoreError(w, r, store.ErrSandboxDeploymentConflict) - return - } - result, err := h.sandboxUpdate(r.Context(), store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input.request(), ExpectedGeneration: *input.ExpectedGeneration}) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -// @Summary Start or escalate a durable sandbox deployment reset -// @Description Archives hosted Sessions and waits for confirmed provider cleanup, preserving history and Files/Artifacts. Auto waits for started or waiting Turns and file writes until the durable deadline; force cancels them. The same clear is idempotent; force escalates auto. Requires the current generation. Self-hosted Sessions are unchanged. -// @Tags Sandbox Manager -// @Produce json -// @Security DeploymentAdminAuth -// @Accept json -// @Param body body store.SandboxResetRequest true "Reset mode and current deployment generation" -// @Success 200 {object} store.RuntimeDeploymentView -// @Failure 400,401,409,500,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/deployment/reset [post] -func (h *Handler) startSandboxReset(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONBodyLimit(w, r, 4096, "Reset request is too large.") - if !ok { - return - } - var input struct { - ExpectedGeneration *uint64 `json:"expected_generation"` - Clear string `json:"clear"` - DeadlineSeconds *int32 `json:"deadline_seconds"` - } - if decodeInputObject(raw, &input, "expected_generation", "clear", "deadline_seconds") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { - writeError(w, http.StatusBadRequest, "invalid_request_error", "A current expected_generation is required.", "expected_generation") - return - } - if input.Clear != "auto" && input.Clear != "force" { - writeError(w, http.StatusBadRequest, "invalid_request_error", "Choose auto or force for clear.", "clear") - return - } - var fields map[string]json.RawMessage - _ = json.Unmarshal(raw, &fields) - if value, present := fields["deadline_seconds"]; present && string(value) == "null" { - writeError(w, http.StatusBadRequest, "invalid_request_error", "deadline_seconds must be an integer when supplied.", "deadline_seconds") - return - } - if input.DeadlineSeconds != nil && (input.Clear != "auto" || *input.DeadlineSeconds < 300 || *input.DeadlineSeconds > 86400) { - writeCoreError(w, http.StatusBadRequest, "invalid_request_error", "deadline_seconds applies only to auto and must be between 300 and 86400.", CoreErrorDetails{"min": CoreErrorNumber(300), "max": CoreErrorNumber(86400)}, "deadline_seconds") - return - } - if h.sandboxReset == nil { - writeStoreError(w, r, store.ErrSandboxDeploymentConflict) - return - } - setAdminAuditSource(r, "") - result, err := h.sandboxReset(r.Context(), store.SandboxResetRequest{ExpectedGeneration: *input.ExpectedGeneration, Clear: input.Clear, DeadlineSeconds: input.DeadlineSeconds}) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} - -// @Summary Cancel a sandbox deployment reset -// @Description Restores admission but never restores Sessions already archived. With no reset running this is an idempotent read, provided the generation still matches. -// @Tags Sandbox Manager -// @Produce json -// @Security DeploymentAdminAuth -// @Param expected_generation query integer true "Current deployment generation" -// @Success 200 {object} store.RuntimeDeploymentView -// @Failure 400,401,409,500,503 {object} CoreErrorResponse -// @Router /core/v1/sandbox/deployment/reset [delete] -func (h *Handler) cancelSandboxReset(w http.ResponseWriter, r *http.Request) { - query, err := parseResetGeneration(r) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request_error", "A single current expected_generation is required.", "expected_generation") - return - } - if h.sandboxResetCancel == nil { - writeStoreError(w, r, store.ErrSandboxDeploymentConflict) - return - } - setAdminAuditSource(r, "") - result, err := h.sandboxResetCancel(r.Context(), query) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} -func parseResetGeneration(r *http.Request) (uint64, error) { - query, err := url.ParseQuery(r.URL.RawQuery) - if err != nil || len(query) != 1 || len(query["expected_generation"]) != 1 { - return 0, store.ErrInvalidInput - } - return strconv.ParseUint(query.Get("expected_generation"), 10, 64) -} - -// Null is an invalid explicit credential, not the omitted-key preservation path. -func nullSandboxKey(raw json.RawMessage) bool { - var body struct { - E2B map[string]json.RawMessage `json:"e2b"` - } - if json.Unmarshal(raw, &body) != nil { - return false - } - key, present := body.E2B["api_key"] - return present && string(key) == "null" -} diff --git a/services/agents-api/internal/api/sandbox_deployment_setup_test.go b/services/agents-api/internal/api/sandbox_deployment_setup_test.go deleted file mode 100644 index ab7f140f0..000000000 --- a/services/agents-api/internal/api/sandbox_deployment_setup_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestSandboxDeploymentSetupRequiresAdministratorAndStrictBody(t *testing.T) { - project, _ := NewAuthenticator([]APIKey{callerBinding()}) - admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) - calls := 0 - initialize := func(_ context.Context, input store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) { - calls++ - if input.Provider == "microsandbox" { - return store.RuntimeDeploymentView{}, store.ErrSandboxDeploymentConflict - } - return store.RuntimeDeploymentView{Provider: input.Provider}, nil - } - h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxDeploymentSetup(initialize)) - if err != nil { - t.Fatal(err) - } - for _, test := range []struct { - token, body string - status, calls int - }{ - {"caller", `{"provider":"docker","expected_generation":0}`, 401, 0}, - {"node-credential", `{"provider":"docker","expected_generation":0}`, 401, 0}, - {"enrollment-token", `{"provider":"docker","expected_generation":0}`, 401, 0}, - {"administrator", `{"provider":"docker","unexpected":true}`, 400, 0}, - {"administrator", `{"provider":"docker"}`, 400, 0}, - {"administrator", `{"provider":"docker","expected_generation":null}`, 400, 0}, - {"administrator", `{"provider":"docker","expected_generation":0}`, 200, 1}, - {"administrator", `{"provider":"microsandbox","expected_generation":0}`, 409, 2}, - } { - request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(test.body)) - request.Header.Set("Authorization", "Bearer "+test.token) - result := httptest.NewRecorder() - h.ServeHTTP(result, request) - if result.Code != test.status || calls != test.calls { - t.Fatal(result.Code, calls, result.Body.String()) - } - } -} - -func TestSandboxDeploymentSetupFileModeReturnsConflict(t *testing.T) { - h := &Handler{} - request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(`{"provider":"docker","expected_generation":0}`)) - result := httptest.NewRecorder() - h.initializeSandboxDeployment(result, request) - if result.Code != http.StatusConflict || !strings.Contains(result.Body.String(), "sandbox_deployment_conflict") { - t.Fatal(result.Code, result.Body.String()) - } -} diff --git a/services/agents-api/internal/api/session_artifacts.go b/services/agents-api/internal/api/session_artifacts.go deleted file mode 100644 index f0710cef5..000000000 --- a/services/agents-api/internal/api/session_artifacts.go +++ /dev/null @@ -1,138 +0,0 @@ -package api - -import ( - "context" - "io" - "net/http" - "path" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type SessionArtifactStore interface { - GetSessionArtifact(context.Context, string, string, string) (store.SessionArtifact, error) - ListSessionArtifacts(context.Context, string, string, string, string, int, bool) (store.ArtifactPage, error) - ReadSessionArtifact(context.Context, string, string, string, func(store.SessionArtifact, io.Reader) error) error - DeleteSessionArtifact(context.Context, string, string, string) error -} - -func WithSessionArtifacts(s SessionArtifactStore) Option { - return func(h *Handler) { h.artifacts = s } -} - -func (h *Handler) artifactsReady(w http.ResponseWriter) bool { - if h.artifacts == nil { - writeError(w, http.StatusServiceUnavailable, "artifact_storage_unavailable", "Artifact storage is unavailable.") - return false - } - return true -} - -// @Summary List immutable Session artifacts -// @Description Lists published outputs independently of Environment availability. Sorting uses publication time and ID. A later Turn publishes a path again only when it is new, its bytes changed, or no Artifact remains for it. A malformed environment_id matches nothing. An after value that is not an Artifact of this Session, including a malformed one, returns 400 invalid_request_error with the message "after is not a valid artifact ID". The local default page size is 20; exact upstream defaults remain unverified. -// @Tags Artifacts -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param environment_id query string false "Producing Environment ID; an unknown or malformed ID returns an empty page" -// @Param after query string false "Last immutable artifact ID" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Publication order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.SessionArtifactList -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/artifacts [get] -func (h *Handler) listSessionArtifacts(w http.ResponseWriter, r *http.Request) { - if !h.artifactsReady(w) { - return - } - options, ok := readPage(w, r, "environment_id") - if !ok { - return - } - page, err := h.artifacts.ListSessionArtifacts(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), r.URL.Query().Get("environment_id"), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - data := make([]v1.SessionArtifact, 0, len(page.Artifacts)) - for _, artifact := range page.Artifacts { - data = append(data, artifactResponse(artifact)) - } - first, last := listBounds(data, func(value v1.SessionArtifact) string { return value.ID }) - writeJSON(w, http.StatusOK, v1.SessionArtifactList{Object: "list", Data: data, HasMore: page.NextCursor != "", FirstID: first, LastID: last}) -} - -// @Summary Retrieve immutable artifact metadata -// @Tags Artifacts -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param artifact_id path string true "Artifact ID" -// @Success 200 {object} v1.SessionArtifact -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [get] -func (h *Handler) getSessionArtifact(w http.ResponseWriter, r *http.Request) { - if !h.artifactsReady(w) { - return - } - artifact, err := h.artifacts.GetSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, artifactResponse(artifact)) -} - -// @Summary Delete a published artifact -// @Description Deletes the published copy without modifying its original workspace file. Already admitted content reads may finish; later reads reject. -// @Tags Artifacts -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param artifact_id path string true "Artifact ID" -// @Success 200 {object} v1.SessionArtifactDeleted -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [delete] -func (h *Handler) deleteSessionArtifact(w http.ResponseWriter, r *http.Request) { - if !h.artifactsReady(w) { - return - } - id := chi.URLParam(r, "artifact_id") - if err := h.artifacts.DeleteSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), id); err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.SessionArtifactDeleted{ID: id, Object: "agent.session.artifact.deleted", Deleted: true}) -} - -// @Summary Download immutable artifact bytes -// @Description Streams stored bytes after tenant and Session authorization, including after Environment expiration. Exact upstream headers and Range behavior remain unverified. -// @Tags Artifacts -// @Produce octet-stream -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param artifact_id path string true "Artifact ID" -// @Success 200 {file} binary -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/artifacts/{artifact_id}/content [get] -func (h *Handler) sessionArtifactContent(w http.ResponseWriter, r *http.Request) { - if !h.artifactsReady(w) { - return - } - serveStoredContent(w, r, func(ctx context.Context, consume func(string, int64, io.Reader) error) error { - return h.artifacts.ReadSessionArtifact(ctx, tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id"), func(a store.SessionArtifact, body io.Reader) error { - return consume(path.Base(a.Path), a.SizeBytes, body) - }) - }) -} - -func artifactResponse(a store.SessionArtifact) v1.SessionArtifact { - return v1.SessionArtifact{ID: a.ID, CreatedAt: a.CreatedAt.Unix(), EnvironmentID: a.EnvironmentID, - Object: "agent.session.artifact", Path: a.Path, SessionID: a.SessionID, SizeBytes: a.SizeBytes, TurnID: a.TurnID} -} diff --git a/services/agents-api/internal/api/session_artifacts_test.go b/services/agents-api/internal/api/session_artifacts_test.go deleted file mode 100644 index 7d2d35982..000000000 --- a/services/agents-api/internal/api/session_artifacts_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type artifactFixture struct { - artifact store.SessionArtifact - tenant, session, id, environment, cursor string - limit int - ascending bool - empty bool - err error - calls int -} - -func (f *artifactFixture) GetSessionArtifact(_ context.Context, tenant, session, id string) (store.SessionArtifact, error) { - f.calls++ - f.tenant, f.session, f.id = tenant, session, id - return f.artifact, f.err -} - -func (f *artifactFixture) ListSessionArtifacts(_ context.Context, tenant, session, environment, cursor string, limit int, ascending bool) (store.ArtifactPage, error) { - f.calls++ - f.tenant, f.session, f.environment, f.cursor, f.limit, f.ascending = tenant, session, environment, cursor, limit, ascending - if f.empty { - return store.ArtifactPage{Artifacts: []store.SessionArtifact{}}, f.err - } - return store.ArtifactPage{Artifacts: []store.SessionArtifact{f.artifact}, NextCursor: f.artifact.ID}, f.err -} - -func (f *artifactFixture) ReadSessionArtifact(ctx context.Context, tenant, session, id string, consume func(store.SessionArtifact, io.Reader) error) error { - a, err := f.GetSessionArtifact(ctx, tenant, session, id) - if err != nil { - return err - } - return consume(a, bytes.NewReader([]byte{0, 255, 1})) -} - -func (f *artifactFixture) DeleteSessionArtifact(ctx context.Context, tenant, session, id string) error { - _, err := f.GetSessionArtifact(ctx, tenant, session, id) - return err -} - -type artifactResponseRecorder struct{ *httptest.ResponseRecorder } - -func (*artifactResponseRecorder) SetWriteDeadline(time.Time) error { return nil } - -func TestSessionArtifactRoutesAndPublicProjection(t *testing.T) { - f := &artifactFixture{artifact: store.SessionArtifact{ID: "artifact", SessionID: "session", EnvironmentID: "environment", TurnID: "turn", Path: "/workspace/outputs/a.bin", SizeBytes: 3, CreatedAt: time.Unix(123, 456)}} - h, _, tenant := testHandler(t, WithSessionArtifacts(f)) - request := func(method, suffix, beta string) *httptest.ResponseRecorder { - r := httptest.NewRequest(method, "/v1/agents/sessions/session/artifacts"+suffix, nil) - r.Header.Set("Authorization", "Bearer test-api-key") - r.Header.Set("OpenAI-Beta", beta) - r.Header.Set("X-Tenant-ID", "untrusted") - w := &artifactResponseRecorder{httptest.NewRecorder()} - h.ServeHTTP(w, r) - return w.ResponseRecorder - } - w := request("GET", "/artifact", "agents=v1") - var got v1.SessionArtifact - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Object != "agent.session.artifact" || got.CreatedAt != 123 || got.SizeBytes != 3 || got.Path != f.artifact.Path || got.TurnID != "turn" || got.EnvironmentID != "environment" || got.SessionID != "session" || got.ID != "artifact" { - t.Fatalf("metadata projection: %d %s", w.Code, w.Body) - } - if f.tenant != tenant || f.session != "session" || f.id != "artifact" { - t.Fatalf("untrusted request scope: %+v", f) - } - w = request("GET", "?environment_id=environment&after=previous&limit=1&order=asc", "agents=v1") - var page v1.SessionArtifactList - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || !page.HasMore || len(page.Data) != 1 || f.environment != "environment" || f.cursor != "previous" || f.limit != 1 || !f.ascending { - t.Fatalf("filtered page: %d %s %+v", w.Code, w.Body, f) - } - // The list envelope matches the Session, Turn and Item lists (HE-53). - if page.Object != "list" || page.FirstID == nil || *page.FirstID != "artifact" || page.LastID == nil || *page.LastID != "artifact" { - t.Fatalf("list envelope: %s", w.Body) - } - f.empty = true - if w := request("GET", "?environment_id=not-a-uuid", "agents=v1"); w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` || f.environment != "not-a-uuid" { - t.Fatalf("empty list envelope: %d %s", w.Code, w.Body) - } - f.empty = false - w = request("GET", "/artifact/content", "agents=v1") - if w.Code != 200 || !bytes.Equal(w.Body.Bytes(), []byte{0, 255, 1}) || w.Header().Get("Content-Type") != "application/octet-stream" || w.Header().Get("Content-Length") != "3" { - t.Fatalf("content: %d %s %v", w.Code, w.Body, w.Header()) - } - w = request("DELETE", "/artifact", "agents=v1") - var deleted v1.SessionArtifactDeleted - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &deleted) != nil || !deleted.Deleted || deleted.ID != "artifact" || deleted.Object != "agent.session.artifact.deleted" { - t.Fatalf("delete: %d %s", w.Code, w.Body) - } - for _, suffix := range []string{"?limit=0", "?limit=101", "?limit=-1", "?order=random", "?environment_id=a&environment_id=b", "?limit=1&limit=2"} { - before := f.calls - if w := request("GET", suffix, "agents=v1"); w.Code != 400 || f.calls != before || !strings.Contains(w.Body.String(), `"code":"invalid_request_error"`) { - t.Fatalf("invalid query reached storage: %s %d", suffix, w.Code) - } - } - for _, suffix := range []string{"?unknown=x&tenant_id=other", "/artifact?unknown=x", "/artifact/content?unknown=x"} { - before := f.calls - if w := request("GET", suffix, "agents=v1"); w.Code != 200 || f.calls != before+1 || f.tenant != tenant { - t.Fatalf("unknown query was not ignored: %s %d", suffix, w.Code) - } - } - for _, route := range []struct{ method, suffix string }{{"GET", ""}, {"GET", "/artifact"}, {"GET", "/artifact/content"}, {"DELETE", "/artifact"}} { - before := f.calls - if w := request(route.method, route.suffix, ""); w.Code != 400 || f.calls != before { - t.Fatalf("missing Beta accepted: %s %s %d", route.method, route.suffix, w.Code) - } - f.err = store.ErrNotFound - if w := request(route.method, route.suffix, "agents=v1"); w.Code != 404 { - t.Fatalf("not-found mapping: %s %s %d", route.method, route.suffix, w.Code) - } - } - if w := request(http.MethodPost, "", "agents=v1"); w.Code != 405 { - t.Fatalf("invented create route: %d", w.Code) - } -} diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go deleted file mode 100644 index cd7b9f8bf..000000000 --- a/services/agents-api/internal/api/session_creation_identity.go +++ /dev/null @@ -1,76 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// sessionCreationRequest records caller intent before mutable sources resolve: -// saved Agents, templates, credentials and hosted deployment defaults. A retry -// then returns the committed Session even after those sources change. Other -// inline requests keep the resolved-request retry rule; the store leaves the -// deployment default out of that hash, so it cannot change their identity. -func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if input.Agent != nil && input.Agent.Model != nil && (input.Environment == nil || input.Environment.Type != "openai_hosted") && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { - return nil, nil - } - agentID := "" - if input.AgentID != nil { - agentID = *input.AgentID - } - var environment any = input.Environment - if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() || (input.XAgentsCore != nil && len(input.XAgentsCore.Environment) > 0) { - environment = input.originalEnvironment - if len(input.originalEnvironment) == 0 { - environment = input.templateEnvironment - } - } - return json.Marshal(struct { - Execution *v1.SessionExecutionInput `json:"x_agents_core,omitempty"` - AgentID string `json:"agent_id"` - Agent map[string]json.RawMessage `json:"agent,omitempty"` - Environment any `json:"environment"` - Metadata map[string]string `json:"metadata,omitempty"` - VaultIDs []string `json:"vault_ids,omitempty"` - InitialInputs []store.Input `json:"initial_inputs,omitempty"` - }{input.XAgentsCore, agentID, input.agentFields, environment, input.Metadata, input.VaultIDs, initial}) -} - -func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, key string, request json.RawMessage, stream bool) bool { - if len(request) == 0 { - return false - } - result, err := h.store.FindSessionCreation(r.Context(), tenantID(r), key, request, sessionCreator(r)) - if errors.Is(err, store.ErrNotFound) { - return false - } - if err != nil { - writeStoreError(w, r, err) - return true - } - if stream { - events, ok := h.store.(eventStore) - if !ok { - writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") - return true - } - if !h.auditSessionOperation(w, r, result.Session.ID, "create") { - return true - } - // Recorded-intent lookup finds an existing creation, which sends no events. - h.respondSessionCreationStream(w, r, events, nil, result) - } else { - session, err := h.store.GetSession(r.Context(), tenantID(r), result.Session.ID) - if err != nil { - writeStoreError(w, r, err) - } else if h.auditSessionOperation(w, r, session.ID, "create") { - h.respondSessionStatus(w, r, session, http.StatusCreated) - } - } - return true -} diff --git a/services/agents-api/internal/api/session_deletion.go b/services/agents-api/internal/api/session_deletion.go deleted file mode 100644 index 48071f683..000000000 --- a/services/agents-api/internal/api/session_deletion.go +++ /dev/null @@ -1,43 +0,0 @@ -package api - -import ( - "bytes" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -// @Summary Delete an execution Session -// @Description Removes a durably idle or failed Session and its history from the public API. A Session whose root Turn is queued, in progress or waiting (including required actions) or whose input reservation is pending returns 409 conflict_error and is left unchanged; cancel it and wait until it is idle before deleting. Subagent child Turns and pending Environment file writes are not checked and do not block deletion. Repeating the deletion of the caller's own deleted Session returns the same confirmation; missing and foreign Sessions return 404. Internal records and native history are retained pending separate physical cleanup; overlapping stream timing remains unverified. -// @Tags Sessions -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Success 200 {object} v1.SessionDeleted -// @Failure 400,401,404,409,413,500 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id} [delete] -func (h *Handler) deleteSession(w http.ResponseWriter, r *http.Request) { - body, ok := readJSONBody(w, r) - if !ok { - return - } - if len(bytes.TrimSpace(body)) > 0 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session deletion does not accept a request body.") - return - } - id := chi.URLParam(r, "session_id") - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil { - writeStoreError(w, r, store.ErrNotFound) - return - } - if err := h.store.DeleteSession(r.Context(), tenantID(r), id); err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.SessionDeleted{ID: parsed.String(), Object: "agent.session.deleted", Deleted: true}) -} diff --git a/services/agents-api/internal/api/session_diagnostics.go b/services/agents-api/internal/api/session_diagnostics.go deleted file mode 100644 index c3c9812e8..000000000 --- a/services/agents-api/internal/api/session_diagnostics.go +++ /dev/null @@ -1,177 +0,0 @@ -package api - -import ( - "context" - "net/http" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type DiagnosticFailure struct { - Code string `json:"code" enums:"harness_error,model_provider_required,runtime_unavailable,runtime_disconnected,runtime_preparation_failed,execution_interrupted,delivery_unconfirmed,input_rejected,executor_protocol_error,core_storage_failed,internal_error,environment_connection_timeout,environment_unavailable,environment_provisioning_failed,authentication_error,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,invalid_request,resource_not_found,request_timeout,context_length_exceeded,cyber_policy,connection_failed"` - Params CoreErrorDetails `json:"params" swaggertype:"object"` - FailedAt *time.Time `json:"failed_at" extensions:"x-nullable"` -} - -type SessionDiagnosticFailure struct { - DiagnosticFailure - Source string `json:"source" enums:"turn,environment,environment_input"` - TurnID string `json:"turn_id,omitempty"` -} - -type SessionDiagnostics struct { - Object string `json:"object" enums:"core.session_diagnostics"` - SessionID string `json:"session_id"` - Status string `json:"status" enums:"idle,in_progress,requires_action,failed"` - Failure *SessionDiagnosticFailure `json:"failure" extensions:"x-nullable"` -} - -type ItemDiagnosticTiming struct { - ItemID string `json:"item_id"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at" extensions:"x-nullable"` - ObservedDurationMS *int64 `json:"observed_duration_ms" extensions:"x-nullable"` -} - -type TurnDiagnostics struct { - Object string `json:"object" enums:"core.turn_diagnostics"` - SessionID string `json:"session_id"` - TurnID string `json:"turn_id"` - Status string `json:"status"` - Failure *DiagnosticFailure `json:"failure" extensions:"x-nullable"` - Items []ItemDiagnosticTiming `json:"items"` - ItemsTruncated bool `json:"items_truncated"` -} - -type sessionDiagnosticsStore interface { - GetSessionDiagnosticsSnapshot(context.Context, string, string) (store.Session, error) - GetTurnDiagnosticsSnapshot(context.Context, string, string, string) (store.TurnDiagnosticsSnapshot, error) -} - -// @Summary Retrieve root Session diagnostics -// @Description Core key only. Safe failure categories from one committed snapshot; no native text or historical inference. -// @Tags Sessions -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param session_id path string true "Session ID" -// @Success 200 {object} SessionDiagnostics -// @Failure 400,401,404,500,503 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/sessions/{session_id}/diagnostics [get] -func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request) { - source, ok := h.store.(sessionDiagnosticsStore) - if !ok { - writeError(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Session diagnostics are unavailable.") - return - } - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - session, err := source.GetSessionDiagnosticsSnapshot(ctx, tenantID(r), chi.URLParam(r, "session_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - public, err := sessionResponse(session, h.executorURL) - if err != nil { - writeStoreError(w, r, err) - return - } - response := SessionDiagnostics{Object: "core.session_diagnostics", SessionID: public.ID, Status: public.Status} - if public.Status == "failed" { - failure := SessionDiagnosticFailure{DiagnosticFailure: DiagnosticFailure{Code: "internal_error", Params: CoreErrorDetails{}}} - switch { - case session.EnvironmentFailure != nil: - failure.Source = "environment" - failure.Code = "environment_provisioning_failed" - failure.FailedAt = diagnosticTime(session.EnvironmentFailure.FailedAt) - failure.Params = provisioningFailureParams(session.EnvironmentFailure.Detail) - case session.EnvironmentInputActivity != nil: - failure.Source = "environment_input" - failure.FailedAt = diagnosticTime(session.EnvironmentInputActivity.LastActiveAt) - switch session.EnvironmentInputActivity.Failure { - case "": - failure.Code = "environment_connection_timeout" - case "environment_unavailable": - failure.Code = "environment_unavailable" - case "runtime_preparation_failed": - failure.Code = "runtime_preparation_failed" - case "model_provider_required": - failure.Code = "model_provider_required" - } - case session.LastTurn != nil: - failure.Source, failure.TurnID = "turn", session.LastTurn.ID - failure.DiagnosticFailure = *turnDiagnosticFailure(*session.LastTurn) - } - response.Failure = &failure - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, response) -} - -// @Summary Retrieve root Turn diagnostics -// @Description Core key only. At most 1000 root Item receipt timings in public Item order. Receipt intervals are not native execution durations. -// @Tags Turns -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param session_id path string true "Session ID" -// @Param turn_id path string true "Root Turn ID" -// @Success 200 {object} TurnDiagnostics -// @Failure 400,401,404,500,503 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics [get] -func (h *Handler) getTurnDiagnostics(w http.ResponseWriter, r *http.Request) { - source, ok := h.store.(sessionDiagnosticsStore) - if !ok { - writeError(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Session diagnostics are unavailable.") - return - } - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - snapshot, err := source.GetTurnDiagnosticsSnapshot(ctx, tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "turn_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - public, err := turnResponse(snapshot.Session, snapshot.Turn) - if err != nil { - writeStoreError(w, r, err) - return - } - response := TurnDiagnostics{Object: "core.turn_diagnostics", SessionID: public.SessionID, TurnID: public.ID, Status: public.Status, Failure: turnDiagnosticFailure(snapshot.Turn), Items: []ItemDiagnosticTiming{}, ItemsTruncated: snapshot.ItemsTruncated} - for _, value := range snapshot.Items { - item := ItemDiagnosticTiming{ItemID: value.ItemID, StartedAt: value.StartedAt, CompletedAt: value.CompletedAt} - if value.CompletedAt != nil { - duration := value.CompletedAt.Sub(value.StartedAt).Milliseconds() - item.ObservedDurationMS = &duration - } - response.Items = append(response.Items, item) - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, response) -} - -func diagnosticTime(value time.Time) *time.Time { - if value.IsZero() { - return nil - } - return &value -} - -func provisioningFailureParams(detail *store.ProvisioningFailureDetail) CoreErrorDetails { - params := CoreErrorDetails{"step": CoreErrorNull(), "index": CoreErrorNull(), "exit_code": CoreErrorNull()} - if detail == nil { - return params - } - if detail.Step != nil { - params["step"] = CoreErrorString(*detail.Step) - } - if detail.Index != nil { - params["index"] = CoreErrorNumber(float64(*detail.Index)) - } - if detail.ExitCode != nil { - params["exit_code"] = CoreErrorNumber(float64(*detail.ExitCode)) - } - return params -} diff --git a/services/agents-api/internal/api/session_diagnostics_test.go b/services/agents-api/internal/api/session_diagnostics_test.go deleted file mode 100644 index c76309dd1..000000000 --- a/services/agents-api/internal/api/session_diagnostics_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestDiagnosticsCoreHandlerDatabaseBoundary(t *testing.T) { - s, pool := diagnosticDatabase(t) - h, _, tenant := adminTestHandler(t, func(h *Handler) { h.store = s }) - session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: identity.Subject{Kind: "service_account", ID: "diagnostic-test"}, Engine: "codex", IdempotencyKey: "diagnostics", Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) - if err != nil { - t.Fatal(err) - } - receipt, err := s.SubmitMessage(t.Context(), tenant, session.ID, "input", json.RawMessage(`{"text":"input-secret-canary"}`)) - if err != nil { - t.Fatal(err) - } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { - t.Fatal(err) - } - if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"device_disconnected","error":"Bearer raw-secret-canary https://private.example/key","done":{"native_id":"secret-native-canary"}}`)}); err != nil { - t.Fatal(err) - } - base := adminSessionsPath + session.ID - for _, path := range []string{base + "/diagnostics", base + "/turns/" + receipt.TurnID + "/diagnostics"} { - if w := diagnosticRequest(h, path, ""); w.Code != 401 { - t.Fatal("unauthed diagnostics", w.Code, w.Body) - } - if w := diagnosticRequest(h, path, "Bearer caller"); w.Code != 401 { - t.Fatal("Project credential entered Core route", w.Code, w.Body) - } - w := diagnosticRequest(h, path, "Bearer admin") - if w.Code != 200 || w.Header().Get("Cache-Control") != "no-store" || !strings.Contains(w.Body.String(), `"code":"runtime_disconnected"`) || strings.Contains(w.Body.String(), "canary") || strings.Contains(w.Body.String(), "private.example") { - t.Fatal(w.Code, w.Body) - } - } - missing := diagnosticRequest(h, base+"/turns/"+uuid.NewString()+"/diagnostics", "Bearer admin") - malformed := diagnosticRequest(h, base+"/turns/malformed/diagnostics", "Bearer admin") - if missing.Code != 404 || malformed.Code != 404 || missing.Body.String() != malformed.Body.String() { - t.Fatal("missing path semantics changed", missing.Body, malformed.Body) - } - foreign := diagnosticRequest(h, strings.Replace(base, managementProjectID, uuid.NewString(), 1)+"/diagnostics", "Bearer admin") - if foreign.Code != 404 { - t.Fatal("foreign project visible", foreign.Code) - } - if _, err = pool.Exec(t.Context(), "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID); err != nil { - t.Fatal(err) - } - for _, path := range []string{base + "/diagnostics", base + "/turns/" + receipt.TurnID + "/diagnostics"} { - if w := diagnosticRequest(h, path, "Bearer admin"); w.Code != 404 { - t.Fatal("deleted root visible", w.Code, w.Body) - } - } -} - -type diagnosticSnapshotStore struct { - ResourceStore - session store.Session -} - -func (s diagnosticSnapshotStore) GetSessionDiagnosticsSnapshot(context.Context, string, string) (store.Session, error) { - return s.session, nil -} -func (s diagnosticSnapshotStore) GetTurnDiagnosticsSnapshot(context.Context, string, string, string) (store.TurnDiagnosticsSnapshot, error) { - return store.TurnDiagnosticsSnapshot{Session: s.session, Turn: *s.session.LastTurn, Items: []store.ItemDiagnosticTiming{}}, nil -} - -func TestDiagnosticsFailurePrecedenceAndUnknownTime(t *testing.T) { - id, turnID := uuid.NewString(), uuid.NewString() - base := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &store.Turn{ID: turnID, SessionID: id, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed","error":"secret-canary"}`)}} - for _, tc := range []struct { - activity *store.EnvironmentInputActivity - code, source string - }{{nil, "harness_error", "turn"}, {&store.EnvironmentInputActivity{Status: "failed"}, "environment_connection_timeout", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "model_provider_required"}, "model_provider_required", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "runtime_preparation_failed"}, "runtime_preparation_failed", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "secret-canary"}, "internal_error", "environment_input"}} { - value := base - value.EnvironmentInputActivity = tc.activity - h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: value} }) - w := diagnosticRequest(h, adminSessionsPath+id+"/diagnostics", "Bearer admin") - if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":"`+tc.code+`"`) || !strings.Contains(w.Body.String(), `"source":"`+tc.source+`"`) || !strings.Contains(w.Body.String(), `"failed_at":null`) || strings.Contains(w.Body.String(), "canary") { - t.Fatal(w.Code, w.Body) - } - } - base.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "idle", LastActiveAt: time.Now()} - h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: base} }) - if w := diagnosticRequest(h, adminSessionsPath+id+"/diagnostics", "Bearer admin"); w.Code != 200 || !strings.Contains(w.Body.String(), `"failure":null`) { - t.Fatal("public activity precedence changed", w.Code, w.Body) - } -} - -func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T) { - session := hostedFailureSession() - session.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()} - session.LastTurn = &store.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)} - step, index, exit := "setup", 2, 7 - for _, detail := range []*store.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}} { - session.EnvironmentFailure = &store.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail} - h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: session} }) - w := diagnosticRequest(h, adminSessionsPath+session.ID+"/diagnostics", "Bearer admin") - if w.Code != 200 || strings.Contains(w.Body.String(), "canary") || !strings.Contains(w.Body.String(), `"code":"environment_provisioning_failed"`) || !strings.Contains(w.Body.String(), `"source":"environment"`) { - t.Fatal(w.Code, w.Body) - } - want := `"params":{"exit_code":null,"index":null,"step":null}` - if detail != nil { - want = `"params":{"exit_code":7,"index":2,"step":"setup"}` - } - if !strings.Contains(w.Body.String(), want) || !strings.Contains(w.Body.String(), `"failed_at":null`) { - t.Fatal("historical reason parsed or time invented", w.Body) - } - } -} diff --git a/services/agents-api/internal/api/session_execution_configuration.go b/services/agents-api/internal/api/session_execution_configuration.go deleted file mode 100644 index 4458dca5e..000000000 --- a/services/agents-api/internal/api/session_execution_configuration.go +++ /dev/null @@ -1,84 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/go-chi/chi/v5" -) - -type sessionExecutionConfigurationStore interface { - GetSessionExecutionConfiguration(context.Context, string, string) (v1.SessionExecutionConfiguration, error) -} - -// Record selection sources at resolution time. Null Agent extensions reset the -// harness to deployment defaults but do not clear inherited provider bundles. -func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inherited, provider *v1.ModelProviderInput, engine string, raw json.RawMessage) v1.SessionExecutionConfiguration { - var configuration struct { - Agent struct { - Model string `json:"model"` - Core *v1.AgentsCore `json:"x_agents_core"` - } `json:"agent"` - } - _ = json.Unmarshal(raw, &configuration) // The resolved configuration was already validated. - modelSource := "session" - if saved != nil && (input.Agent == nil || input.Agent.Model == nil) { - modelSource = "agent" - } - if input.modelSource != "" { - modelSource = input.modelSource - } - harnessSource := "deployment" - if _, overridden := input.agentFields["x_agents_core"]; overridden { - if input.Agent != nil && input.Agent.XAgentsCore != nil && input.Agent.XAgentsCore.Harness != "" { - harnessSource = "session" - } else if input.Agent != nil && input.Agent.XAgentsCore != nil && saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { - harnessSource = "agent" - } - } else if saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { - harnessSource = "agent" - } - selection := v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} - if provider != nil { - // Deployment defaults are readable with the same Core key, so new - // Sessions record their safe view too; historical rows stay redacted. - selection.Source, selection.Status, selection.Configuration = "deployment", "available", provider.SafeView() - if input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { - selection.Source = "session" - } else if inherited != nil { - selection.Source = "agent" - } - } - native := json.RawMessage(`{}`) - if configuration.Agent.Core != nil { - native = v1.ResolvedHarnessConfig(configuration.Agent.Core.HarnessConfig) - } - nativeSource := input.harnessConfigSource - if nativeSource == "" { - nativeSource = "unknown" - } - return v1.SessionExecutionConfiguration{ - HarnessConfig: v1.ExecutionHarnessConfigSelection{Value: native, Source: nativeSource}, - Object: "agent.session.execution_configuration", SchemaVersion: 1, - Model: v1.ExecutionSelection{Value: &configuration.Agent.Model, Source: modelSource}, - Harness: v1.ExecutionSelection{Value: &engine, Source: harnessSource}, ModelProvider: selection, - } -} - -// getSessionExecutionConfiguration serves the administrator per-Session read. -func (h *Handler) getSessionExecutionConfiguration(w http.ResponseWriter, r *http.Request) { - source, ok := h.store.(sessionExecutionConfigurationStore) - if !ok { - writeError(w, http.StatusServiceUnavailable, "execution_configuration_unavailable", "Session execution configuration is unavailable.") - return - } - configuration, err := source.GetSessionExecutionConfiguration(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, configuration) -} diff --git a/services/agents-api/internal/api/session_execution_configuration_test.go b/services/agents-api/internal/api/session_execution_configuration_test.go deleted file mode 100644 index f204fb175..000000000 --- a/services/agents-api/internal/api/session_execution_configuration_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestExecutionConfigurationSources(t *testing.T) { - provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://saved.example/v1", APIKey: "secret-canary"} - saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "codex", ModelProvider: provider.SafeView()}}} - for _, tc := range []struct { - name, agent, extension string - saved *v1.SavedAgent - inherited, provider *v1.ModelProviderInput - modelSource, harnessSource, providerSource, status string - }{ - {"saved", ``, ``, saved, provider, provider, "agent", "agent", "agent", "available"}, - {"model override", `,"agent":{"model":"override"}`, ``, saved, provider, provider, "session", "agent", "agent", "available"}, - {"harness override", `,"agent":{"x_agents_core":{"harness":"codex"}}`, ``, saved, provider, provider, "agent", "session", "agent", "available"}, - {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "deployment", "agent", "available"}, - {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "agent", "session", "available"}, - {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "available"}, - {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "available"}, - {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, nil, "session", "session", "unknown", "unavailable"}, - } { - t.Run(tc.name, func(t *testing.T) { - var decoded decodedSessionRequest - if err := json.Unmarshal([]byte(`{"environment":{"type":"openai_hosted"}`+tc.agent+tc.extension+`}`), &decoded); err != nil { - t.Fatal(err) - } - input, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - p := sessionExecutionProjection(input, tc.saved, tc.inherited, tc.provider, "codex", json.RawMessage(`{"agent":{"model":"resolved"}}`)) - if p.Model.Source != tc.modelSource || p.Harness.Source != tc.harnessSource || p.ModelProvider.Source != tc.providerSource || p.ModelProvider.Status != tc.status { - t.Fatalf("wrong sources: %#v", p) - } - raw, _ := json.Marshal(p) - if strings.Contains(string(raw), "secret-canary") || (tc.status != "available" && p.ModelProvider.Configuration != nil) { - t.Fatal("private provider leaked") - } - }) - } -} - -type executionConfigurationReader struct { - ResourceStore - calls int - tenant, id string - value v1.SessionExecutionConfiguration - err error -} - -func (s *executionConfigurationReader) GetSessionExecutionConfiguration(_ context.Context, tenant, id string) (v1.SessionExecutionConfiguration, error) { - s.calls++ - s.tenant, s.id = tenant, id - return s.value, s.err -} - -func TestExecutionConfigurationReadBoundary(t *testing.T) { - s := &executionConfigurationReader{value: v1.SessionExecutionConfiguration{Object: "agent.session.execution_configuration", SchemaVersion: 1, SessionID: "frozen"}} - h, _, tenant := adminTestHandler(t, func(h *Handler) { h.store = s }) - for _, tc := range []struct { - auth string - err error - status int - }{ - {"", nil, 401}, {"Bearer admin", nil, 200}, {"Bearer admin", store.ErrNotFound, 404}, - } { - s.err = tc.err - before := s.calls - r := httptest.NewRequest(http.MethodGet, adminSessionsPath+"frozen/execution-configuration?ignored=true", nil) - r.Header.Set("Authorization", tc.auth) - r.Header.Set("OpenAI-Beta", "agents=v1") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != tc.status { - t.Fatalf("status %d: %s", w.Code, w.Body) - } - if tc.auth == "" { - if s.calls != before { - t.Fatal("unauthorized read reached storage") - } - continue - } - if s.tenant != tenant || s.id != "frozen" { - t.Fatal("wrong tenant/resource") - } - if w.Code == 200 && w.Header().Get("Cache-Control") != "no-store" { - t.Fatal("snapshot response cacheable") - } - } -} diff --git a/services/agents-api/internal/api/session_initial_input.go b/services/agents-api/internal/api/session_initial_input.go deleted file mode 100644 index 5f5af4cd6..000000000 --- a/services/agents-api/internal/api/session_initial_input.go +++ /dev/null @@ -1,32 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func initialSessionInputs(raw json.RawMessage) ([]store.Input, error) { - raw = bytes.TrimSpace(raw) - if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { - return nil, nil - } - if raw[0] == '"' { - var text string - if err := json.Unmarshal(raw, &text); err != nil { - return nil, store.ErrInvalidInput - } - raw, _ = json.Marshal([]v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}) - } - // Preserve the array's original fields for the shared strict message decoder. - event, err := json.Marshal(struct { - Type string `json:"type"` - Input json.RawMessage `json:"input"` - }{Type: "agent.session.input.message", Input: raw}) - if err != nil { - return nil, store.ErrInvalidInput - } - return executionInputs([]json.RawMessage{event}) -} diff --git a/services/agents-api/internal/api/session_tools.go b/services/agents-api/internal/api/session_tools.go deleted file mode 100644 index b97e8de2e..000000000 --- a/services/agents-api/internal/api/session_tools.go +++ /dev/null @@ -1,86 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { - tools := make([]json.RawMessage, len(input)) - functions := make([]v1.FunctionToolInput, 0, len(input)) - positions := make([]int, 0, len(input)) - servers := map[string]bool{} - search := false - controls := map[string]bool{} - for i, raw := range input { - var kind struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &kind) != nil { - return nil, errors.New("Invalid execution tool configuration.") - } - switch kind.Type { - case "programmatic_tool_calling", "web_search": - if controls[kind.Type] { - return nil, errors.New("Execution requires distinct tool controls.") - } - controls[kind.Type] = true - var resolved json.RawMessage - var err error - if kind.Type == "web_search" { - resolved, err = resolveDisabledWebSearch(raw) - } else { - resolved, err = resolveProgrammaticTool(raw) - var value struct { - Enabled bool `json:"enabled"` - } - if err == nil { - _ = json.Unmarshal(resolved, &value) - if value.Enabled { - err = errors.New("Programmatic tool calling is not qualified for execution.") - } - } - } - if err != nil { - return nil, err - } - tools[i] = resolved - case "tool_search": - if search || decodeInputObject(raw, &kind, "type") != nil { - return nil, errors.New("Execution requires one type-only tool_search declaration.") - } - search = true - tools[i], _ = json.Marshal(kind) - case "mcp": - resolved, err := resolveMCPTool(raw, false) - if err != nil { - return nil, err - } - var tool v1.MCPTool - if json.Unmarshal(resolved, &tool) != nil || servers[tool.ServerLabel] { - return nil, errors.New("Execution requires distinct MCP server labels.") - } - servers[tool.ServerLabel] = true - tools[i] = resolved - case "function": - var function v1.FunctionToolInput - if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { - return nil, errors.New("Invalid execution function fields.") - } - functions = append(functions, function) - positions = append(positions, i) - default: - return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, qualified HTTP MCP and explicit disabled controls.") - } - } - resolved, err := resolveFunctions(functions) - if err != nil { - return nil, err - } - for i, value := range resolved { - tools[positions[i]] = value - } - return tools, nil -} diff --git a/services/agents-api/internal/api/session_write_audit.go b/services/agents-api/internal/api/session_write_audit.go deleted file mode 100644 index 264cc7af3..000000000 --- a/services/agents-api/internal/api/session_write_audit.go +++ /dev/null @@ -1,29 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" -) - -type sessionWriteAuditor interface { - AuditSessionOperation(context.Context, string, string, string) error -} - -func (h *Handler) auditSessionOperation(w http.ResponseWriter, r *http.Request, sessionID, action string) bool { - if _, ok := writeaudit.FromContext(r.Context()); !ok { - return true - } - auditor, ok := h.store.(sessionWriteAuditor) - if !ok { - writeStoreError(w, r, errors.New("session write audit is unavailable")) - return false - } - if err := auditor.AuditSessionOperation(r.Context(), tenantID(r), sessionID, action); err != nil { - writeStoreError(w, r, err) - return false - } - return true -} diff --git a/services/agents-api/internal/api/session_write_audit_test.go b/services/agents-api/internal/api/session_write_audit_test.go deleted file mode 100644 index 4922d8fc8..000000000 --- a/services/agents-api/internal/api/session_write_audit_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -// General wire fixtures do not persist resources; dedicated audit fixtures below -// check the no-op/replay boundary independently of business Store auditing. -func (s *recordingStore) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { - if auditor, ok := s.ResourceStore.(sessionWriteAuditor); ok { - return auditor.AuditSessionOperation(ctx, tenant, session, action) - } - return nil -} - -func (f *streamFixture) AuditSessionOperation(context.Context, string, string, string) error { - return nil -} - -type auditedSessionFixture struct { - streamFixture - err error - actions []string - source writeaudit.Source -} - -func (f *auditedSessionFixture) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { - return store.SessionCreation{Session: f.session}, nil -} - -func (f *auditedSessionFixture) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { - if tenant != f.session.TenantID || session != f.session.ID { - return store.ErrNotFound - } - f.source, _ = writeaudit.FromContext(ctx) - f.actions = append(f.actions, action) - return f.err -} - -func TestSessionAuditOnlyRoutesFailClosed(t *testing.T) { - for _, route := range []string{"empty-events", "creation-replay", "stream-replay"} { - for _, fail := range []bool{false, true} { - t.Run(route+map[bool]string{false: "/commit", true: "/rollback"}[fail], func(t *testing.T) { - tenant, session := uuid.NewString(), uuid.NewString() - f := &auditedSessionFixture{streamFixture: streamFixture{session: store.Session{ID: session, TenantID: tenant, Configuration: json.RawMessage(`{"environment":{"type":"none"},"agent":{"id":"agent","model":"test"}}`)}}} - if fail { - f.err = errors.New("audit unavailable") - } - h := &Handler{store: f} - ctx := context.WithValue(t.Context(), principalContextKey{}, identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant}, SubjectKind: "service_account", SubjectID: "test"}) - ctx = writeaudit.WithSource(ctx, writeaudit.Source{TenantID: tenant, RequestID: "request", TraceID: "trace"}) - routeContext := chi.NewRouteContext() - routeContext.URLParams.Add("session_id", session) - ctx = context.WithValue(ctx, chi.RouteCtxKey, routeContext) - r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"events":[]}`)).WithContext(ctx) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - if route == "empty-events" { - h.createEvents(w, r) - } else if !h.recoverSessionCreation(w, r, "key", json.RawMessage(`{}`), route == "stream-replay") { - t.Fatal("replay not handled") - } - wantStatus, wantAction := 201, "create" - if route == "empty-events" { - wantStatus, wantAction = 202, "send_events" - } - if route == "stream-replay" { - wantStatus = 201 - } - if fail { - wantStatus = 500 - } - if w.Code != wantStatus || len(f.actions) != 1 || f.actions[0] != wantAction || f.source.RequestID != "request" { - t.Fatal(w.Code, w.Body.String(), f.actions, f.source) - } - }) - } - } -} diff --git a/services/agents-api/internal/api/skills.go b/services/agents-api/internal/api/skills.go deleted file mode 100644 index ae326fb1c..000000000 --- a/services/agents-api/internal/api/skills.go +++ /dev/null @@ -1,170 +0,0 @@ -package api - -import ( - "context" - "net/http" - "strconv" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type SkillStore interface { - CreateSkill(context.Context, string, []byte) (store.Skill, error) - GetSkill(context.Context, string, string) (store.Skill, error) - UpdateSkillDefault(context.Context, string, string, string) (store.Skill, error) - DeleteSkill(context.Context, string, string) error - ListSkills(context.Context, string, string, int, bool) (store.SkillPage, error) - CreateSkillVersion(context.Context, string, string, []byte, bool) (store.SkillVersion, error) - GetSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) - ReadSkillVersion(context.Context, string, string, string) (store.SkillVersion, []byte, error) - ReadDefaultSkillVersion(context.Context, string, string) (store.SkillVersion, []byte, error) - DeleteSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) - ListSkillVersions(context.Context, string, string, string, int, bool) (store.SkillVersionPage, error) -} - -func WithSkills(s SkillStore) Option { return func(h *Handler) { h.skills = s } } - -func (h *Handler) registerSkillRoutes(r chi.Router) { - r.Post("/v1/skills", h.createSkill) - r.Get("/v1/skills", h.listSkills) - r.Get("/v1/skills/{skill_id}", h.getSkill) - r.Post("/v1/skills/{skill_id}", h.updateSkill) - r.Delete("/v1/skills/{skill_id}", h.deleteSkill) - r.Get("/v1/skills/{skill_id}/content", h.skillContent) - r.Head("/v1/skills/{skill_id}/content", methodNotAllowed) - r.Post("/v1/skills/{skill_id}/versions", h.createSkillVersion) - r.Get("/v1/skills/{skill_id}/versions", h.listSkillVersions) - r.Get("/v1/skills/{skill_id}/versions/{version}", h.getSkillVersion) - r.Delete("/v1/skills/{skill_id}/versions/{version}", h.deleteSkillVersion) - r.Get("/v1/skills/{skill_id}/versions/{version}/content", h.skillVersionContent) - r.Head("/v1/skills/{skill_id}/versions/{version}/content", methodNotAllowed) -} - -func (h *Handler) skillsReady(w http.ResponseWriter) bool { - if h.skills == nil { - writeError(w, http.StatusServiceUnavailable, "skill_storage_unavailable", "Skill storage is unavailable.") - return false - } - return true -} - -// @Summary Retrieve Skill metadata -// @Description Returns tenant-owned metadata without decrypting contents or starting Runtime. No Beta header is required. -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Success 200 {object} v1.Skill -// @Router /skills/{skill_id} [get] -func (h *Handler) getSkill(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - value, err := h.skills.GetSkill(r.Context(), tenantID(r), chi.URLParam(r, "skill_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, skillResponseResource(value)) -} - -// @Summary Update the default Skill version -// @Description Changes only the tenant-owned default pointer; immutable versions and existing Session snapshots remain unchanged. -// @Tags Skills -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Param body body v1.SkillUpdateRequest true "Default version" -// @Success 200 {object} v1.Skill -// @Router /skills/{skill_id} [post] -func (h *Handler) updateSkill(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - body, ok := readJSONBodyLimit(w, r, 64<<10, "Request exceeds 64 KiB.") - if !ok { - return - } - var input v1.SkillUpdateRequest - if decodeInputObject(body, &input, "default_version") != nil || input.DefaultVersion == "" { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - value, err := h.skills.UpdateSkillDefault(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), input.DefaultVersion) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, skillResponseResource(value)) -} - -// @Summary Delete a Skill and its versions -// @Description Deletes tenant-owned source bundles. Existing Session installation snapshots remain independent. -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Success 200 {object} v1.SkillDeleted -// @Router /skills/{skill_id} [delete] -func (h *Handler) deleteSkill(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - id := chi.URLParam(r, "skill_id") - if err := h.skills.DeleteSkill(r.Context(), tenantID(r), id); err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.SkillDeleted{ID: id, Object: "skill.deleted", Deleted: true}) -} - -// @Summary Retrieve Skill version metadata -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Param version path string true "Concrete version number" -// @Success 200 {object} v1.SkillVersion -// @Router /skills/{skill_id}/versions/{version} [get] -func (h *Handler) getSkillVersion(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - value, err := h.skills.GetSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, skillVersionResponse(value)) -} - -// @Summary Delete a Skill version -// @Description Deleting the only remaining version also deletes the Skill; existing Session installation snapshots remain independent. The default version cannot be deleted while other versions remain. Version numbers are never reused. -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Param version path string true "Concrete version number" -// @Success 200 {object} v1.SkillVersionDeleted -// @Router /skills/{skill_id}/versions/{version} [delete] -func (h *Handler) deleteSkillVersion(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - value, err := h.skills.DeleteSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.SkillVersionDeleted{ID: value.ID, Object: "skill.version.deleted", Version: strconv.FormatInt(value.Version, 10), Deleted: true}) -} - -func skillResponseResource(s store.Skill) v1.Skill { - return v1.Skill{ID: s.ID, Object: "skill", CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, DefaultVersion: strconv.FormatInt(s.DefaultVersion, 10), LatestVersion: strconv.FormatInt(s.LatestVersion, 10)} -} -func skillVersionResponse(s store.SkillVersion) v1.SkillVersion { - return v1.SkillVersion{ID: s.ID, Object: "skill.version", SkillID: s.SkillID, CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, Version: strconv.FormatInt(s.Version, 10)} -} diff --git a/services/agents-api/internal/api/skills_list.go b/services/agents-api/internal/api/skills_list.go deleted file mode 100644 index 85ba28d11..000000000 --- a/services/agents-api/internal/api/skills_list.go +++ /dev/null @@ -1,77 +0,0 @@ -package api - -import ( - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/go-chi/chi/v5" -) - -// @Summary List Skills -// @Description Lists tenant-owned metadata in timestamp order. Default page size 20, maximum 100. Limit 0 returns an empty page whose has_more reports whether any Skill follows the cursor; exact hosted defaults remain unverified. -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param after query string false "Skill resource cursor" -// @Param limit query integer false "Page size; 0 returns an empty page" default(20) minimum(0) maximum(100) -// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) -// @Success 200 {object} v1.SkillList -// @Router /skills [get] -func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - options, ok := readPage(w, r) - if !ok { - return - } - page, err := h.skills.ListSkills(r.Context(), tenantID(r), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - result := v1.SkillList{Object: "list", Data: make([]v1.Skill, 0, len(page.Skills)), HasMore: page.HasMore} - for _, value := range page.Skills { - result.Data = append(result.Data, skillResponseResource(value)) - } - if len(result.Data) > 0 { - result.FirstID = &result.Data[0].ID - result.LastID = &result.Data[len(result.Data)-1].ID - } - writeJSON(w, http.StatusOK, result) -} - -// @Summary List Skill versions -// @Description Orders by version number; after identifies a version resource, not a version number. An after value that does not begin with skillver, or a version of another Skill, returns 400 invalid_value with param after; a missing version returns not found. No contents are decrypted. Limit 0 returns an empty page whose has_more reports whether any version follows the cursor. -// @Tags Skills -// @Produce json -// @Security BearerAuth -// @Param skill_id path string true "Skill ID" -// @Param after query string false "Version resource cursor" -// @Param limit query integer false "Page size; 0 returns an empty page" default(20) minimum(0) maximum(100) -// @Param order query string false "Version order; omit for descending, explicit empty values are invalid" Enums(asc,desc) -// @Success 200 {object} v1.SkillVersionList -// @Router /skills/{skill_id}/versions [get] -func (h *Handler) listSkillVersions(w http.ResponseWriter, r *http.Request) { - if !h.skillsReady(w) { - return - } - options, ok := readPage(w, r) - if !ok { - return - } - page, err := h.skills.ListSkillVersions(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - result := v1.SkillVersionList{Object: "list", Data: make([]v1.SkillVersion, 0, len(page.Versions)), HasMore: page.HasMore} - for _, value := range page.Versions { - result.Data = append(result.Data, skillVersionResponse(value)) - } - if len(result.Data) > 0 { - result.FirstID = &result.Data[0].ID - result.LastID = &result.Data[len(result.Data)-1].ID - } - writeJSON(w, http.StatusOK, result) -} diff --git a/services/agents-api/internal/api/source_files.go b/services/agents-api/internal/api/source_files.go deleted file mode 100644 index 940f93632..000000000 --- a/services/agents-api/internal/api/source_files.go +++ /dev/null @@ -1,84 +0,0 @@ -package api - -import ( - "context" - "io" - "net/http" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type SourceFileStore interface { - CreateSourceFile(context.Context, string, func(io.Writer) (store.SourceFileUpload, error)) (store.SourceFile, error) - GetSourceFile(context.Context, string, string) (store.SourceFile, error) - ListSourceFiles(context.Context, string, string, int, bool, *string) (store.SourceFilePage, error) - ReadSourceFile(context.Context, string, string, func(store.SourceFile, io.Reader) error) error - DeleteSourceFile(context.Context, string, string) error -} - -func WithSourceFiles(s SourceFileStore) Option { - return func(h *Handler) { h.sourceFiles = s } -} - -func (h *Handler) sourceFilesAvailable(w http.ResponseWriter) bool { - if h.sourceFiles == nil { - writeError(w, http.StatusServiceUnavailable, "file_storage_unavailable", "Source file storage is unavailable.") - return false - } - return true -} - -// @Summary Retrieve source file metadata -// @Description Returns immutable project-owned user_data file metadata. No Beta header is required. Other purposes, expiration and full hosted status/error semantics remain unimplemented or unverified. -// @Tags Files -// @Produce json -// @Security BearerAuth -// @Param file_id path string true "Source file ID" -// @Success 200 {object} v1.SourceFile -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /files/{file_id} [get] -func (h *Handler) getSourceFile(w http.ResponseWriter, r *http.Request) { - if !h.sourceFilesAvailable(w) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - file, err := h.sourceFiles.GetSourceFile(ctx, tenantID(r), chi.URLParam(r, "file_id")) - if err != nil { - writeStoreError(w, r, err, "id") - return - } - writeJSON(w, http.StatusOK, sourceFileResponse(file)) -} - -// @Summary Delete a source file -// @Description Atomically deletes project-owned metadata and stored bytes. Already-admitted reads or copies may finish. Workspace copies remain independent. Historical WAL/backups are not erased. No Beta header is required; exact hosted concurrent deletion/error semantics remain unverified. -// @Tags Files -// @Produce json -// @Security BearerAuth -// @Param file_id path string true "Source file ID" -// @Success 200 {object} v1.SourceFileDeleted -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /files/{file_id} [delete] -func (h *Handler) deleteSourceFile(w http.ResponseWriter, r *http.Request) { - if !h.sourceFilesAvailable(w) { - return - } - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - id := chi.URLParam(r, "file_id") - if err := h.sourceFiles.DeleteSourceFile(ctx, tenantID(r), id); err != nil { - writeStoreError(w, r, err, "id") - return - } - writeJSON(w, http.StatusOK, v1.SourceFileDeleted{ID: id, Object: "file", Deleted: true}) -} - -func sourceFileResponse(file store.SourceFile) v1.SourceFile { - return v1.SourceFile{ID: file.ID, Object: "file", Bytes: file.SizeBytes, - CreatedAt: file.CreatedAt.Unix(), Filename: file.Filename, - Purpose: file.Purpose, Status: "processed"} -} diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go deleted file mode 100644 index abf06e5c4..000000000 --- a/services/agents-api/internal/api/subagents.go +++ /dev/null @@ -1,116 +0,0 @@ -package api - -import ( - "context" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/go-chi/chi/v5" -) - -// SubagentStore reads tenant-authorized, persisted public resources. Implementations -// must enforce every supplied parent scope, including the pagination cursor. -type SubagentStore interface { - GetSubagent(context.Context, string, string, string) (v1.Subagent, error) - ListSubagents(context.Context, string, string, string, int, bool) (v1.SubagentList, error) - ListSubagentItems(context.Context, string, string, string, string, int, bool) (v1.ItemList, error) - GetSubagentTurn(context.Context, string, string, string, string) (v1.Turn, error) - ListSubagentTurns(context.Context, string, string, string, string, int, bool) (v1.TurnList, error) - ListSubagentTurnItems(context.Context, string, string, string, string, string, int, bool) (v1.ItemList, error) -} - -func WithSubagents(s SubagentStore) Option { return func(h *Handler) { h.subagents = s } } - -func (h *Handler) registerSubagentRoutes(r chi.Router) { - const root = "/agents/sessions/{session_id}/subagents" - r.Get(root, h.listSubagents) - r.Get(root+"/{subagent_id}", h.getSubagent) - r.Get(root+"/{subagent_id}/items", h.listSubagentItems) - r.Get(root+"/{subagent_id}/turns", h.listSubagentTurns) - r.Get(root+"/{subagent_id}/turns/{turn_id}", h.getSubagentTurn) - r.Get(root+"/{subagent_id}/turns/{turn_id}/items", h.listSubagentTurnItems) -} - -func (h *Handler) subagentsReady(w http.ResponseWriter) bool { - if h.subagents == nil { - writeError(w, http.StatusServiceUnavailable, "subagent_storage_unavailable", "Subagent storage is unavailable.") - return false - } - return true -} - -// @Summary Retrieve a Session Subagent -// @Description Returns this Session's persisted Subagent. Active includes idle between Turns. Resuming preserves opened_at and clears closed_at. Unknown or inaccessible parent scopes return not found. -// @Tags Subagents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param subagent_id path string true "Subagent ID" -// @Success 200 {object} v1.Subagent -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/subagents/{subagent_id} [get] -func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { - if !h.subagentsReady(w) { - return - } - value, err := h.subagents.GetSubagent(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, value) -} - -// @Summary List Session Subagents -// @Description Includes nested and closed Subagents. Cursors are Subagents of the same tenant and Session. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid resource ID in `after`". A limit outside 1–100 is rejected. -// @Tags Subagents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param after query string false "Last Subagent ID from the previous page" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.SubagentList -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/subagents [get] -func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { - options, ok := readPage(w, r) - if !ok || !h.subagentsReady(w) { - return - } - page, err := h.subagents.ListSubagents(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, subagentListResponse(page.Data, page.HasMore)) -} - -// @Summary List a Subagent's Items -// @Description Returns only this Subagent's own Items across all its Turns, not its descendants' Items. Cursors are Items of the same tenant, Session and Subagent. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid session item ID in `after`". -// @Tags Subagents -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param subagent_id path string true "Subagent ID" -// @Param after query string false "Last Item ID from the previous page" -// @Param limit query int false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) -// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.ItemList -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/items [get] -func (h *Handler) listSubagentItems(w http.ResponseWriter, r *http.Request) { - options, ok := readClampedPage(w, r) - if !ok || !h.subagentsReady(w) { - return - } - page, err := h.subagents.ListSubagentItems(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, itemListResponse(page.Data, page.HasMore)) -} diff --git a/services/agents-api/internal/api/subagents_test.go b/services/agents-api/internal/api/subagents_test.go deleted file mode 100644 index 736579b66..000000000 --- a/services/agents-api/internal/api/subagents_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type subagentReadStore struct { - method, tenant, session, subagent, turn, after string - limit int - ascending bool - calls int - empty bool - err error -} - -func (s *subagentReadStore) record(method, tenant, session, subagent, turn, after string, limit int, ascending bool) { - s.method, s.tenant, s.session, s.subagent, s.turn, s.after = method, tenant, session, subagent, turn, after - s.limit, s.ascending = limit, ascending - s.calls++ -} - -func sampleSubagent() v1.Subagent { - return v1.Subagent{ID: "child", Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", OpenedAt: 1700000000, Status: "active"} -} - -// sampleSubagentTurn has the official child Turn shape: agent_id is the Session's -// Agent ID and subagent_id identifies the child. -func sampleSubagentTurn() v1.Turn { - id := "child" - return v1.Turn{ID: "turn", AgentID: "root", SubagentID: &id, SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000001} -} - -func (s *subagentReadStore) GetSubagent(_ context.Context, tenant, session, subagent string) (v1.Subagent, error) { - s.record("get", tenant, session, subagent, "", "", 0, false) - return sampleSubagent(), s.err -} - -func (s *subagentReadStore) ListSubagents(_ context.Context, tenant, session, after string, limit int, asc bool) (v1.SubagentList, error) { - s.record("list", tenant, session, "", "", after, limit, asc) - if s.empty { - return v1.SubagentList{}, s.err - } - return v1.SubagentList{Data: []v1.Subagent{sampleSubagent()}, HasMore: true}, s.err -} - -func (s *subagentReadStore) ListSubagentItems(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.ItemList, error) { - s.record("items", tenant, session, subagent, "", after, limit, asc) - return s.items(), s.err -} - -func (s *subagentReadStore) GetSubagentTurn(_ context.Context, tenant, session, subagent, turn string) (v1.Turn, error) { - s.record("turn", tenant, session, subagent, turn, "", 0, false) - return sampleSubagentTurn(), s.err -} - -func (s *subagentReadStore) ListSubagentTurns(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.TurnList, error) { - s.record("turns", tenant, session, subagent, "", after, limit, asc) - if s.empty { - return v1.TurnList{}, s.err - } - return v1.TurnList{Data: []v1.Turn{sampleSubagentTurn()}, HasMore: true}, s.err -} - -func (s *subagentReadStore) ListSubagentTurnItems(_ context.Context, tenant, session, subagent, turn, after string, limit int, asc bool) (v1.ItemList, error) { - s.record("turn_items", tenant, session, subagent, turn, after, limit, asc) - return s.items(), s.err -} - -func (s *subagentReadStore) items() v1.ItemList { - if s.empty { - return v1.ItemList{} - } - text := "Child result." - return v1.ItemList{Data: []v1.Item{{ID: "item", TurnID: "turn", Type: "agent_message", SenderAgentID: "child", RecipientAgentID: "root", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}, HasMore: true} -} - -// Item lists clamp limit like Session Items; the Subagent and Subagent Turn lists -// reject it, as the official service does. -var subagentRoutes = []struct { - path, method, subagent, turn string - list, clamped bool -}{ - {"", "list", "", "", true, false}, - {"/child", "get", "child", "", false, false}, - {"/child/items", "items", "child", "", true, true}, - {"/child/turns", "turns", "child", "", true, false}, - {"/child/turns/turn", "turn", "child", "turn", false, false}, - {"/child/turns/turn/items", "turn_items", "child", "turn", true, true}, -} - -func requestSubagents(h http.Handler, path, auth, beta string) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/session/subagents"+path, nil) - r.Header.Set("Authorization", auth) - r.Header.Set("OpenAI-Beta", beta) - r.Header.Set("X-Tenant-ID", "untrusted") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - return w -} - -func TestSubagentRoutesPreserveAuthenticatedParentScope(t *testing.T) { - s := &subagentReadStore{} - h, _, tenant := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - t.Run(route.method, func(t *testing.T) { - w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusOK || s.method != route.method || s.tenant != tenant || s.session != "session" || s.subagent != route.subagent || s.turn != route.turn { - t.Fatalf("scope: %d %s; call=%+v", w.Code, w.Body, s) - } - var body map[string]json.RawMessage - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if route.list { - if s.limit != 20 || s.ascending || s.after != "" || string(body["has_more"]) != "true" { - t.Fatalf("list defaults: %+v %s", s, w.Body) - } - // Every Subagent list uses the common envelope. - if string(body["object"]) != `"list"` || len(body["first_id"]) < 3 || string(body["first_id"]) != string(body["last_id"]) { - t.Fatalf("list envelope: %s", w.Body) - } - w = requestSubagents(h, route.path+"?after=last&limit=2&order=asc", "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusOK || s.after != "last" || s.limit != 2 || !s.ascending { - t.Fatalf("pagination: %+v %s", s, w.Body) - } - } else if route.method == "get" { - for _, field := range []string{"closed_at", "name", "instructions"} { - if string(body[field]) != "null" { - t.Fatalf("%s missing null: %s", field, w.Body) - } - } - } else if string(body["agent_id"]) != `"root"` || string(body["subagent_id"]) != `"child"` || string(body["usage"]) != "null" { - t.Fatalf("child Turn identity: %s", w.Body) - } - }) - } -} - -func TestSubagentRoutesRejectInvalidQueriesBeforeStore(t *testing.T) { - s := &subagentReadStore{} - h, _, _ := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - if !route.list { - continue - } - queries := []string{"limit=null", "limit=-1", "limit=2&limit=3", "order=random", "order=asc&order=desc", "after=a&after=b"} - if !route.clamped { - queries = append(queries, "limit=0", "limit=101") - } - for _, query := range queries { - calls := s.calls - w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusBadRequest || s.calls != calls || !strings.Contains(w.Body.String(), `"code":"invalid_request_error"`) { - t.Fatalf("accepted %s?%s: %d %s", route.path, query, w.Code, w.Body) - } - } - } -} - -func TestSubagentItemListsClampLimit(t *testing.T) { - s := &subagentReadStore{} - h, _, tenant := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - if !route.clamped { - continue - } - for query, limit := range map[string]int{"limit=0": 1, "limit=1": 1, "limit=100": 100, "limit=101": 100, "limit=100000": 100} { - calls := s.calls - w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusOK || s.calls != calls+1 || s.method != route.method || s.limit != limit || s.tenant != tenant { - t.Fatalf("%s?%s: %d %s %+v", route.path, query, w.Code, w.Body, s) - } - } - } -} - -func TestSubagentRoutesIgnoreUnknownQueryKeys(t *testing.T) { - s := &subagentReadStore{} - h, _, tenant := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - // Retrieval routes also ignore list keys, which carry no semantics there. - for _, query := range []string{"unknown=1", "tenant_id=foreign&unknown=1&unknown=2", "limit=1&after=a&order=asc"} { - if route.list && strings.Contains(query, "limit") { - continue - } - calls := s.calls - w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusOK || s.calls != calls+1 || s.method != route.method || s.tenant != tenant || s.subagent != route.subagent || s.turn != route.turn { - t.Fatalf("query changed %s?%s: %d %s %+v", route.path, query, w.Code, w.Body, s) - } - if route.list && (s.limit != 20 || s.after != "" || s.ascending) { - t.Fatalf("unknown keys changed pagination: %+v", s) - } - } - } -} - -func TestSubagentRoutesUseExistingAuthenticationAndErrors(t *testing.T) { - s := &subagentReadStore{} - h, _, _ := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - for _, tc := range []struct { - auth, beta string - status int - }{ - {"", "agents=v1", 401}, - {"Bearer wrong", "agents=v1", 401}, - {"Bearer test-api-key", "", 400}, - {"Bearer test-api-key", "agents=v2", 400}, - } { - calls := s.calls - w := requestSubagents(h, route.path, tc.auth, tc.beta) - if w.Code != tc.status || s.calls != calls { - t.Fatalf("authentication %s: %d %s", route.path, w.Code, w.Body) - } - } - for _, tc := range []struct { - err error - status int - }{ - {store.ErrNotFound, 404}, - {store.ErrInvalidInput, 400}, - {errors.New("SECRET native failure"), 500}, - } { - s.err = tc.err - w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") - if w.Code != tc.status || strings.Contains(w.Body.String(), "SECRET") { - t.Fatalf("error %s: %d %s", route.path, w.Code, w.Body) - } - } - s.err = nil - } -} - -func TestSubagentRoutesDistinguishEmptyFromUnavailable(t *testing.T) { - s := &subagentReadStore{empty: true} - h, _, _ := testHandler(t, WithSubagents(s)) - unavailable, _, _ := testHandler(t) - for _, route := range subagentRoutes { - w := requestSubagents(unavailable, route.path, "Bearer test-api-key", "agents=v1") - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("unwired store fabricated success: %d %s", w.Code, w.Body) - } - if route.list { - w = requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") - expected := `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` - if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != expected { - t.Fatalf("empty page: %d %s", w.Code, w.Body) - } - } - } -} - -func TestSubagentRoutesExposeOnlyOfficialReads(t *testing.T) { - s := &subagentReadStore{} - h, _, _ := testHandler(t, WithSubagents(s)) - for _, route := range subagentRoutes { - for _, method := range []string{http.MethodPost, http.MethodPatch, http.MethodDelete} { - r := httptest.NewRequest(method, "/v1/agents/sessions/session/subagents"+route.path, nil) - r.Header.Set("Authorization", "Bearer test-api-key") - r.Header.Set("OpenAI-Beta", "agents=v1") - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != http.StatusMethodNotAllowed || s.calls != 0 { - t.Fatalf("unexpected mutation route %s %s: %d %s", method, route.path, w.Code, w.Body) - } - } - } -} diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go deleted file mode 100644 index 4f51d676b..000000000 --- a/services/agents-api/internal/api/turns.go +++ /dev/null @@ -1,106 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -// @Summary Retrieve an execution Turn -// @Description Returns a root Turn of this Session. A Subagent Turn ID returns the same not found error as a missing Turn; read it through the Subagent Turn routes. -// @Tags Turns -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param turn_id path string true "Turn ID" -// @Success 200 {object} v1.Turn -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/turns/{turn_id} [get] -func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { - sessionID := chi.URLParam(r, "session_id") - turn, err := h.store.GetTurn(r.Context(), tenantID(r), sessionID, chi.URLParam(r, "turn_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) - if err != nil { - writeStoreError(w, r, err) - return - } - response, err := turnResponse(session, turn) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, response) -} - -// @Summary List execution Turns -// @Description Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same Session and tenant; any other after value, including a malformed one or a Subagent Turn ID, returns not found. Usage contains the latest recorded complete token breakdown; missing measurements remain null. -// @Tags Turns -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param after query string false "Last Turn ID from the previous page" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.TurnList -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/turns [get] -func (h *Handler) listTurns(w http.ResponseWriter, r *http.Request) { - options, ok := readPage(w, r) - if !ok { - return - } - sessionID := chi.URLParam(r, "session_id") - session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) - if err != nil { - writeStoreError(w, r, err) - return - } - page, err := h.store.ListTurns(r.Context(), tenantID(r), sessionID, options.after, options.limit, options.ascending) - if err != nil { - writeStoreError(w, r, err) - return - } - response := v1.TurnList{Data: make([]v1.Turn, 0, len(page.Turns)), HasMore: page.NextCursor != ""} - for _, turn := range page.Turns { - item, err := turnResponse(session, turn) - if err != nil { - writeStoreError(w, r, err) - return - } - response.Data = append(response.Data, item) - } - writeJSON(w, http.StatusOK, turnListResponse(response.Data, response.HasMore)) -} - -func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { - var cfg configuration - if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" { - return v1.Turn{}, errors.New("missing stored agent identity") - } - // Session Turns are root Turns, so subagent_id is always null here. - response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} - if turn.Status == store.TurnFailed { - // Native errors can contain secrets; publish a stable category without raw diagnostics. - response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} - } - return response, nil -} - -func unixTime(value time.Time) *int64 { - if value.IsZero() { - return nil - } - seconds := value.Unix() - return &seconds -} diff --git a/services/agents-api/internal/api/usage.go b/services/agents-api/internal/api/usage.go deleted file mode 100644 index a016b69c8..000000000 --- a/services/agents-api/internal/api/usage.go +++ /dev/null @@ -1,14 +0,0 @@ -package api - -import ( - "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func tokenUsage(raw json.RawMessage) *v1.TokenUsage { - var usage *v1.TokenUsage - if json.Unmarshal(raw, &usage) != nil { - return nil - } - return usage -} diff --git a/services/agents-api/internal/api/vaults.go b/services/agents-api/internal/api/vaults.go deleted file mode 100644 index 90b547435..000000000 --- a/services/agents-api/internal/api/vaults.go +++ /dev/null @@ -1,118 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -type VaultStore interface { - CreateVault(context.Context, string, store.CreateVaultInput) (store.Vault, error) - GetVault(context.Context, string, string) (store.Vault, error) - DeleteVault(context.Context, string, string) (string, error) - ListVaults(context.Context, string, string, int, bool, []string) (store.VaultPage, error) -} - -// @Summary Create a Vault -// @Description Creates a project-owned Vault independently of execution. Omitted name stays null; a supplied string is trimmed and must contain 1–256 UTF-8 bytes. Explicit null name is invalid. Omitted/null metadata becomes an empty object; non-string values return invalid_request_error with a metadata. param. Metadata has a local 64 KiB encoded storage bound. U+0000 in stored strings is rejected as a local storage limit. Credentials, Session binding and hosted error/retry parity remain incomplete. -// @Tags Vaults -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param body body v1.CreateVaultRequest true "Vault name and metadata" -// @Success 201 {object} v1.Vault -// @Failure 400,401,413,500 {object} v1.ErrorResponse -// @Router /vaults [post] -func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObject(w, r) - if !ok { - return - } - if writeFieldError(w, metadataTypeError(raw)) { - return - } - var request struct { - Name json.RawMessage `json:"name"` - Metadata map[string]*string `json:"metadata"` - } - if decodeInputObject(raw, &request, "name", "metadata") != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return - } - input := store.CreateVaultInput{} - if len(request.Name) > 0 { - var name *string - if json.Unmarshal(request.Name, &name) != nil || name == nil { - writeError(w, http.StatusBadRequest, "invalid_request", "name must be a string.") - return - } - trimmed, err := normalizedVaultName(*name) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) - return - } - input.Name = &trimmed - } - // Vault metadata has no pair or character limits, only the storage limits. - var err error - input.Metadata, err = stringMetadata(request.Metadata) - if err == nil { - err = metadataCharacterError(input.Metadata) - } - if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "invalid_request", "metadata values must be strings.") - } - return - } - vault, err := h.store.CreateVault(r.Context(), tenantID(r), input) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusCreated, vaultResponse(vault)) -} - -// @Summary Retrieve a Vault -// @Description Reads a Vault owned by the authenticated project without resolving credentials, Sessions or execution devices. Missing and foreign IDs share the same not-found response; exact hosted error semantics remain unverified. -// @Tags Vaults -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param vault_id path string true "Vault ID" -// @Success 200 {object} v1.Vault -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /vaults/{vault_id} [get] -func (h *Handler) getVault(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "vault_id") - if parsed, err := uuid.Parse(id); err != nil || parsed == uuid.Nil { - writeStoreError(w, r, store.ErrNotFound) - return - } - vault, err := h.store.GetVault(r.Context(), tenantID(r), id) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, vaultResponse(vault)) -} - -func vaultResponse(vault store.Vault) v1.Vault { - return v1.Vault{ID: vault.ID, Object: "vault", CreatedAt: vault.CreatedAt.Unix(), Name: vault.Name, Metadata: vault.Metadata} -} - -func normalizedVaultName(name string) (string, error) { - name = strings.TrimSpace(name) - if len(name) == 0 || len(name) > 256 { - return "", errors.New("name must contain 1 to 256 UTF-8 bytes after trimming.") - } - return name, nil -} diff --git a/services/agents-api/internal/api/vaults_delete.go b/services/agents-api/internal/api/vaults_delete.go deleted file mode 100644 index c67367713..000000000 --- a/services/agents-api/internal/api/vaults_delete.go +++ /dev/null @@ -1,39 +0,0 @@ -package api - -import ( - "bytes" - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// @Summary Delete a Vault and all its Credentials -// @Description Atomically removes the authenticated project's Vault and all its stored Credentials without an encryption key, decryption or external requests. Existing Session snapshots, history and recorded retries retain their frozen identities; subsequent credential lookups fail without reselection or anonymous fallback. Already-resolved tokens and running Sessions are not revoked or cancelled. Missing/repeated deletion locally returns 404. Exact hosted archive, post-delete visibility and concurrent/error semantics remain unverified; physical erasure from native history, WAL or backups is not established. -// @Tags Vaults -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param vault_id path string true "Vault ID" -// @Success 200 {object} v1.VaultDeleted -// @Failure 400,401,404,413,500 {object} v1.ErrorResponse -// @Router /vaults/{vault_id} [delete] -func (h *Handler) deleteVault(w http.ResponseWriter, r *http.Request) { - body, ok := readJSONBody(w, r) - if !ok { - return - } - if len(bytes.TrimSpace(body)) > 0 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault deletion does not accept a request body.") - return - } - id, ok := credentialResourceID(w, r, "vault_id") - if !ok { - return - } - deleted, err := h.store.DeleteVault(r.Context(), tenantID(r), id) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, v1.VaultDeleted{ID: deleted, Deleted: true, Object: "vault.deleted"}) -} diff --git a/services/agents-api/internal/api/vaults_delete_test.go b/services/agents-api/internal/api/vaults_delete_test.go deleted file mode 100644 index eabae4dc8..000000000 --- a/services/agents-api/internal/api/vaults_delete_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "net/http/httptest" - "reflect" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func (f *vaultResourceFixture) DeleteVault(_ context.Context, tenant, id string) (string, error) { - f.tenant, f.id, f.calls = tenant, id, f.calls+1 - return f.vault.ID, f.err -} - -func TestVaultDeletionConfirmationAndScope(t *testing.T) { - // Unknown query keys, including a tenant hint, are ignored. - for _, query := range []string{"", "?tenant_id=untrusted&unknown=1"} { - h, f := vaultResourceHandler(t) - w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID+query, "") - var got map[string]any - if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { - t.Fatal("deletion failed", w.Code) - } - want := map[string]any{"id": f.vault.ID, "deleted": true, "object": "vault.deleted"} - if !reflect.DeepEqual(got, want) || f.tenant != f.vault.TenantID || f.id != f.vault.ID || f.calls != 1 { - t.Fatal("deletion changed authenticated scope or confirmation") - } - } -} - -func TestVaultDeletionRejectsBeforeMutation(t *testing.T) { - for _, mode := range []string{"auth", "beta", "body", "null", "invalid", "zero", "zero-query"} { - t.Run(mode, func(t *testing.T) { - h, f := vaultResourceHandler(t) - path, body, status := "/v1/vaults/"+f.vault.ID, "", 400 - switch mode { - case "auth": - status = 401 - case "body": - body = `{"token":"vault-delete-canary"}` - case "null": - body = "null" - case "invalid": - path, status = "/v1/vaults/invalid", 404 - case "zero": - path, status = "/v1/vaults/"+uuid.Nil.String(), 404 - case "zero-query": - path, status = "/v1/vaults/"+uuid.Nil.String()+"?tenant_id=untrusted", 404 - } - r := httptest.NewRequest("DELETE", path, strings.NewReader(body)) - if mode != "auth" { - r.Header.Set("Authorization", "Bearer vault-key") - } - if mode != "beta" { - r.Header.Set("OpenAI-Beta", "agents=v1") - } - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != status || f.calls != 0 || strings.Contains(w.Body.String(), "vault-delete-canary") { - t.Fatal("invalid deletion reached storage or exposed input", w.Code) - } - }) - } - for _, tc := range []struct { - err error - status int - }{{store.ErrNotFound, 404}, {errors.New("vault-delete-canary"), 500}} { - h, f := vaultResourceHandler(t) - f.err = tc.err - w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID, "") - if w.Code != tc.status || strings.Contains(w.Body.String(), "vault-delete-canary") { - t.Fatal("deletion changed error mapping or exposed storage detail") - } - } -} diff --git a/services/agents-api/internal/api/vaults_list.go b/services/agents-api/internal/api/vaults_list.go deleted file mode 100644 index 56c8ae6ad..000000000 --- a/services/agents-api/internal/api/vaults_list.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "net/http" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// @Summary List Vaults -// @Description Lists project-owned Vaults independently of execution. An unknown, malformed or foreign after cursor returns not found. Includes active and archived records by default. Status accepts a scalar, the SDK's status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted errors and concurrent-page behavior remain unverified. Archive/delete lifecycle is not implemented. -// @Tags Vaults -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param after query string false "Last Vault ID from the previous page" -// @Param limit query integer false "Requested page size, clamped to 1–100" default(20) -// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) -// @Param status query string false "Scalar status filter" Enums(active,archived) -// @Param status[] query []string false "Array status filter; combined with status as a union" collectionFormat(multi) Enums(active,archived) -// @Success 200 {object} v1.VaultList -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /vaults [get] -func (h *Handler) listVaults(w http.ResponseWriter, r *http.Request) { - options, statuses, ok := readVaultPage(w, r) - if !ok { - return - } - page, err := h.store.ListVaults(r.Context(), tenantID(r), options.after, options.limit, options.ascending, statuses) - if err != nil { - writeStoreError(w, r, err) - return - } - response := v1.VaultList{Object: "list", Data: make([]v1.Vault, 0, len(page.Vaults)), HasMore: page.NextCursor != ""} - for _, vault := range page.Vaults { - response.Data = append(response.Data, vaultResponse(vault)) - } - if len(response.Data) > 0 { - response.FirstID = &response.Data[0].ID - response.LastID = &response.Data[len(response.Data)-1].ID - } - writeJSON(w, http.StatusOK, response) -} diff --git a/services/agents-api/internal/api/write_audit.go b/services/agents-api/internal/api/write_audit.go deleted file mode 100644 index fe2e6b528..000000000 --- a/services/agents-api/internal/api/write_audit.go +++ /dev/null @@ -1,164 +0,0 @@ -package api - -import ( - "context" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// WriteAuditStore exposes safe read models, never request bodies or credentials. -type WriteAuditStore interface { - GetResourceOwners(context.Context, string, string, []string) ([]store.ResourceOwner, error) - ListWriteOperations(context.Context, string, store.WriteOperationFilter) (store.WriteOperationPage, error) -} - -type ResourceOwnerList struct { - Data []store.ResourceOwner `json:"data"` -} - -func WithWriteAudit(s WriteAuditStore, auth *DeploymentAuthenticator) Option { - return func(h *Handler) { - h.writeAudit = s - if auth != nil { - h.deploymentAuth = auth - } - } -} - -func (h *Handler) writeAuditScope(w http.ResponseWriter, r *http.Request, allowed ...string) (url.Values, string, bool) { - values, err := url.ParseQuery(r.URL.RawQuery) - if err != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return nil, "", false - } - for name, entries := range values { - recognized := false - for _, field := range allowed { - if name == field { - recognized = true - } - } - if !recognized || len(entries) != 1 || entries[0] == "" { - writeStoreError(w, r, store.ErrInvalidInput) - return nil, "", false - } - } - tenant, ok := r.Context().Value(adminTenantContextKey{}).(string) - if !ok || tenant == "" { - writeStoreError(w, r, store.ErrNotFound) - return nil, "", false - } - return values, tenant, true -} - -// @Summary Batch lookup resource creation keys -// @Description Core key only. The Project ID path selects its space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. -// @Tags Write Audit -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param resource_type query string true "Resource type" -// @Param resource_ids query string true "Comma-separated public resource IDs, maximum 100" -// @Success 200 {object} api.ResourceOwnerList -// @Failure 400,401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/resource-owners [get] -func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { - values, tenant, ok := h.writeAuditScope(w, r, "resource_type", "resource_ids") - if !ok { - return - } - ids := strings.Split(values.Get("resource_ids"), ",") - if !store.ValidAuditResourceType(values.Get("resource_type")) || len(ids) > 100 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - for _, id := range ids { - if id == "" || len(id) > 256 || strings.TrimSpace(id) != id { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - } - owners, err := h.writeAudit.GetResourceOwners(r.Context(), tenant, values.Get("resource_type"), ids) - if err != nil { - writeStoreError(w, r, err) - return - } - if owners == nil { - owners = []store.ResourceOwner{} - } - writeJSON(w, http.StatusOK, ResourceOwnerList{Data: owners}) -} - -// @Summary Query API-key write operations -// @Description Core key only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. -// @Tags Write Audit -// @Produce json -// @Security DeploymentAdminAuth -// @Param project_id path string true "Project ID" -// @Param key_id query string false "Recorded creator key ID" -// @Param resource_type query string false "Resource type" -// @Param resource_id query string false "Public resource ID" -// @Param created_after query string false "Inclusive RFC3339 timestamp" -// @Param created_before query string false "Exclusive RFC3339 timestamp" -// @Param limit query int false "Page size, 1-100, default 50" -// @Param after query string false "Opaque next_cursor from the preceding page" -// @Success 200 {object} store.WriteOperationPage -// @Failure 400,401,404,500 {object} CoreErrorResponse -// @Router /core/v1/projects/{project_id}/write-operations [get] -func (h *Handler) listWriteOperations(w http.ResponseWriter, r *http.Request) { - values, tenant, ok := h.writeAuditScope(w, r, "key_id", "resource_type", "resource_id", "created_after", "created_before", "limit", "after") - if !ok { - return - } - filter := store.WriteOperationFilter{KeyID: values.Get("key_id"), ResourceType: values.Get("resource_type"), ResourceID: values.Get("resource_id"), After: values.Get("after"), Limit: 50} - if filter.ResourceType != "" && !store.ValidAuditResourceType(filter.ResourceType) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - for _, value := range []string{filter.KeyID, filter.ResourceID} { - if len(value) > 256 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - } - if len(filter.After) > 2048 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - if value := values.Get("limit"); value != "" { - n, err := strconv.Atoi(value) - if err != nil || n < 1 || n > 100 { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - filter.Limit = n - } - for key, target := range map[string]**time.Time{"created_after": &filter.CreatedAfter, "created_before": &filter.CreatedBefore} { - if value := values.Get(key); value != "" { - parsed, err := time.Parse(time.RFC3339Nano, value) - if err != nil { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - *target = &parsed - } - } - if filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - page, err := h.writeAudit.ListWriteOperations(r.Context(), tenant, filter) - if err != nil { - writeStoreError(w, r, err) - return - } - if page.Data == nil { - page.Data = []store.WriteOperation{} - } - writeJSON(w, http.StatusOK, page) -} diff --git a/services/agents-api/internal/api/write_audit_test.go b/services/agents-api/internal/api/write_audit_test.go deleted file mode 100644 index c4d0e09d8..000000000 --- a/services/agents-api/internal/api/write_audit_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" - "github.com/google/uuid" -) - -type auditQueryFixture struct { - tenant, resourceType string - ids []string - filter store.WriteOperationFilter - calls int -} - -func (s *auditQueryFixture) GetResourceOwners(_ context.Context, tenant, kind string, ids []string) ([]store.ResourceOwner, error) { - s.calls++ - s.tenant = tenant - s.resourceType = kind - s.ids = ids - result := make([]store.ResourceOwner, len(ids)) - for i, id := range ids { - result[i].ResourceID = id - } - return result, nil -} -func (s *auditQueryFixture) ListWriteOperations(_ context.Context, tenant string, filter store.WriteOperationFilter) (store.WriteOperationPage, error) { - s.calls++ - s.tenant = tenant - s.filter = filter - return store.WriteOperationPage{}, nil -} -func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { - key := callerBinding() - auth, err := NewAuthenticator([]APIKey{key}) - if err != nil { - t.Fatal(err) - } - admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) - if err != nil { - t.Fatal(err) - } - queries := &auditQueryFixture{} - h, err := NewHandler(&recordingStore{}, auth, "codex", WithWriteAudit(queries, admin), WithProjectAPIKeys(&projectKeyStoreFixture{project: store.ProjectBinding{Project: store.Project{ID: key.ProjectID}, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID}}}}, admin)) - if err != nil { - t.Fatal(err) - } - owners := "/core/v1/projects/" + key.ProjectID + "/resource-owners?resource_type=agent&resource_ids=first,second" - for _, token := range []string{"", "caller", "foreign"} { - w := projectKeyHTTP(h, "GET", owners, token, "") - if w.Code != 401 || queries.calls != 0 { - t.Fatalf("unauthorized query %d calls%d", w.Code, queries.calls) - } - } - w := projectKeyHTTP(h, "GET", owners, "admin", "") - if w.Code != 200 || queries.tenant != key.TenantID || queries.resourceType != "agent" || len(queries.ids) != 2 { - t.Fatalf("batch %d %s", w.Code, w.Body) - } - if !strings.Contains(w.Body.String(), `"api_key":null`) { - t.Fatalf("history must be null: %s", w.Body) - } - history := "/core/v1/projects/" + key.ProjectID + "/write-operations?" - w = projectKeyHTTP(h, "GET", history+"key_id=some-key&resource_type=credential&resource_id=resource&limit=2&after=cursor&created_after=2026-01-01T00:00:00Z&created_before=2026-02-01T00:00:00Z", "admin", "") - if w.Code != 200 || queries.filter.Limit != 2 || queries.filter.KeyID != "some-key" || queries.filter.After != "cursor" || queries.filter.CreatedAfter == nil || queries.filter.CreatedBefore == nil { - t.Fatalf("history %d %s filter%+v", w.Code, w.Body, queries.filter) - } - for _, path := range []string{ - owners + "&resource_type=file", strings.Replace(owners, "agent", "unknown", 1), strings.Replace(owners, "first,second", "", 1), - owners + "&tenant_id=foreign", owners + "&bad=%GG", strings.Replace(owners, "first,second", strings.Repeat("id,", 100)+"id", 1), - history + "limit=0", history + "limit=101", history + "limit=bad", history + "limit=", history + "created_after=yesterday", history + "resource_type=unknown", - history + "created_after=2026-02-01T00:00:00Z&created_before=2026-01-01T00:00:00Z", - } { - count := queries.calls - w = projectKeyHTTP(h, "GET", path, "admin", "") - if w.Code != 400 || queries.calls != count { - t.Errorf("invalid query %s returned%d", path, w.Code) - } - } - count := queries.calls - w = projectKeyHTTP(h, "GET", strings.Replace(owners, key.ProjectID, "missing-project", 1), "admin", "") - if w.Code != 404 || queries.calls != count { - t.Fatalf("missing binding %d", w.Code) - } - w = projectKeyHTTP(h, "POST", owners, "admin", `{}`) - if w.Code != 405 { - t.Fatalf("readonly endpoint %d", w.Code) - } -} - -func TestAuthenticatedWriteProvenance(t *testing.T) { - key := callerBinding() - fixture, _ := NewAuthenticator([]APIKey{key}) - binding, _ := fixture.keys.ResolveProjectAPIKey(t.Context(), key.TokenSHA256) - binding.Key = store.ProjectAPIKey{ID: uuid.NewString(), Name: "SDK", Prefix: "pc_12345678"} - keys := &projectKeyStoreFixture{binding: binding} - auth, _ := NewDatabaseAuthenticator(keys) - h := &Handler{auth: auth} - var source writeaudit.Source - var got bool - handler := agentsResponseHeaders(log.HTTPMiddleware(h.authenticateCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - source, got = writeaudit.FromContext(r.Context()) - w.WriteHeader(204) - }), true))) - for _, test := range []struct { - method, path string - present bool - }{{"POST", "/v1/agents", true}, {"DELETE", "/v1/files/file-one", true}, {"GET", "/v1/agents", false}, {"POST", "/core/v1/anything", false}} { - r := httptest.NewRequest(test.method, test.path, nil) - r.Header.Set("Authorization", "Bearer issued-project-key") - r.Header.Set("X-API-Key-ID", "forged") - r.Header.Set("X-Request-Id", "forged") - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - if w.Code != 204 || got != test.present { - t.Fatalf("source eligibility %d %v", w.Code, got) - } - if got && (source.KeyID != binding.Key.ID || source.Kind != "issued" || source.TenantID != key.TenantID || source.RequestID != w.Header().Get("X-Request-Id") || source.RequestID == "forged" || len(source.TraceID) != 32) { - t.Fatalf("source %+v", source) - } - } - keys.resolve = store.ErrNotFound - if w := projectKeyHTTP(handler, "POST", "/v1/agents", "issued-project-key", ""); w.Code != 401 { - t.Fatalf("revoked key %d", w.Code) - } -} - -func TestAuditQueryJSONSafeFields(t *testing.T) { - now := time.Now().UTC() - raw, err := json.Marshal(ResourceOwnerList{Data: []store.ResourceOwner{{ResourceID: "r", APIKey: &store.AuditAPIKey{ID: "key", Name: "sdk", Prefix: "pc_prefix", Kind: "issued", RevokedAt: &now}}}}) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"id", "name", "prefix", "kind", "revoked_at"} { - if !strings.Contains(string(raw), `"`+name+`"`) { - t.Fatal(name) - } - } - for _, name := range []string{"token_sha256", "binding_digest", "tenant_id"} { - if strings.Contains(string(raw), name) { - t.Fatal(name) - } - } -} diff --git a/services/agents-api/internal/engine/configuration_test.go b/services/agents-api/internal/engine/configuration_test.go deleted file mode 100644 index b615a6140..000000000 --- a/services/agents-api/internal/engine/configuration_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package engine - -import ( - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" -) - -func TestProviderDeclarationsAgreeWithAdmission(t *testing.T) { - for _, kind := range (Catalog{}).Kinds() { - t.Run(kind, func(t *testing.T) { - declared, ok := builtin.Registry().Lookup(kind) - if !ok { - t.Fatal("qualified harness has no provider declaration") - } - for _, protocol := range []string{"responses", "anthropic", "unknown"} { - provider, supported := declared.Provider(protocol) - input := v1.ModelProviderInput{Protocol: protocol, BaseURL: "https://example.test", APIKey: "private-fixture", ContextWindow: 100, MaxOutputTokens: 20} - if (input.ValidateHarness(kind) == nil) != supported || (v1.ValidateModelProtocol(protocol, kind) == nil) != supported { - t.Fatalf("protocol %q disagrees with validation", protocol) - } - if !supported { - continue - } - input.ContextWindow, input.MaxOutputTokens = 0, 0 - if (input.ValidateHarness(kind) != nil) != provider.RequiresTokenLimits { - t.Fatalf("token requirements disagree for %q", protocol) - } - if provider.RequiresTokenLimits { - input.ContextWindow = 100 - if input.ValidateHarness(kind) == nil { - t.Fatal("missing output limit accepted") - } - } - } - }) - } -} diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go deleted file mode 100644 index 9f3d50e5e..000000000 --- a/services/agents-api/internal/engine/mcode.go +++ /dev/null @@ -1,30 +0,0 @@ -package engine - -import ( - "errors" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// WhitespaceOnlyText stays unqualified: the native runtime refuses such prompts -// with "Local message content or attachments are required.". -func mcodeProfile() Profile { - return Profile{MCPOrigins: []string{"environment"}, MCPBearer: true, ProgrammaticToolCallingDisable: true, Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { - if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { - return ErrInvalidInput - } - return rejectSubagentTools(a, "mcp") - }, ValidateTools: func(_ *v1.Environment, _ bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if len(functions) > 0 { - return errors.New("The configured engine does not support public functions.") - } - for _, server := range mcp { - if server.ServerLabel == "oac_workspace" || server.AllowedTools != nil || server.Required { - return errors.New("The configured engine requires an unreserved MCP label, allowed_tools=null and required=false.") - } - } - return nil - }} -} diff --git a/services/agents-api/internal/engine/mcp.go b/services/agents-api/internal/engine/mcp.go deleted file mode 100644 index c5c7caa4f..000000000 --- a/services/agents-api/internal/engine/mcp.go +++ /dev/null @@ -1,32 +0,0 @@ -package engine - -import ( - "errors" - "slices" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// ValidateMCPOrigins preserves outbound authority independently of Harness names. -// Workspace connections never stand in for service-network connections. -func (p Profile) ValidateMCPOrigins(environment *v1.Environment, hasDaemon bool, servers []proto.MCPHTTPServer) error { - for _, server := range servers { - switch server.ConnectionOrigin { - case "service": - if environment == nil || environment.Type != "none" || hasDaemon { - return errors.New("Service-origin MCP requires the service-side environment:none profile.") - } - case "environment": - if environment == nil || (environment.Type != "openai_hosted" && environment.Type != "self_hosted") || hasDaemon { - return errors.New("Environment-origin MCP requires a managed or self-hosted execution Environment.") - } - default: - return errors.New("MCP requires an explicit connection origin.") - } - if !slices.Contains(p.MCPOrigins, server.ConnectionOrigin) { - return errors.New("The configured engine does not support this MCP connection origin.") - } - } - return nil -} diff --git a/services/agents-api/internal/engine/mcp_test.go b/services/agents-api/internal/engine/mcp_test.go deleted file mode 100644 index be70bad26..000000000 --- a/services/agents-api/internal/engine/mcp_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package engine - -import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "testing" -) - -func TestMCPOriginQualification(t *testing.T) { - for _, kind := range []string{"codex", "claude_sdk", "mcode"} { - profile, ok := (Catalog{}).Lookup(kind) - if !ok { - t.Fatal(kind) - } - for _, placement := range []string{"none", "self_hosted", "openai_hosted"} { - for _, origin := range []string{"service", "environment", "", "unknown"} { - servers := []proto.MCPHTTPServer{{ConnectionOrigin: origin, ServerLabel: "proof", ServerURL: "https://example.test/mcp"}} - allowed := origin == "environment" && placement != "none" || origin == "service" && placement == "none" && kind != "mcode" - if err := profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, false, servers); (err == nil) != allowed { - t.Fatalf("%s/%s/%s: %v", kind, placement, origin, err) - } - if profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, true, servers) == nil { - t.Fatal("legacy daemon placement admitted") - } - } - } - } -} -func TestMiniMaxMCPPoliciesRejectInsteadOfDropping(t *testing.T) { - p, _ := (Catalog{}).Lookup("mcode") - empty := []string{} - named := []string{"proof"} - for _, allowed := range []*[]string{nil, &empty, &named} { - for _, required := range []bool{false, true} { - server := proto.MCPHTTPServer{ConnectionOrigin: "environment", ServerLabel: "proof", ServerURL: "https://example.test", AllowedTools: allowed, Required: required} - err := p.ValidateTools(&v1.Environment{Type: "self_hosted"}, false, nil, []proto.MCPHTTPServer{server}) - if (err == nil) != (allowed == nil && !required) { - t.Fatal("unsupported MCP policy accepted", err) - } - } - } -} -func TestMCPOriginCatalogIsImmutable(t *testing.T) { - p := Profile{MCPOrigins: []string{"environment"}} - c := NewCatalog(map[string]Profile{"fixture": p}) - p.MCPOrigins[0] = "service" - first, _ := c.Lookup("fixture") - if first.MCPOrigins[0] != "environment" { - t.Fatal("mutable source") - } - first.MCPOrigins[0] = "service" - second, _ := c.Lookup("fixture") - if second.MCPOrigins[0] != "environment" { - t.Fatal("mutable lookup") - } -} diff --git a/services/agents-api/internal/engine/subagents.go b/services/agents-api/internal/engine/subagents.go deleted file mode 100644 index b92ae40d9..000000000 --- a/services/agents-api/internal/engine/subagents.go +++ /dev/null @@ -1,28 +0,0 @@ -package engine - -import ( - "encoding/json" - "errors" - "slices" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// rejectSubagentTools keeps unqualified native combinations in adapter profiles. -func rejectSubagentTools(agent v1.Agent, unsupported ...string) error { - if !agent.MultiAgent.Enabled { - return nil - } - for _, raw := range agent.Tools { - var tool struct { - Type string `json:"type"` - } - if json.Unmarshal(raw, &tool) != nil { - return ErrInvalidInput - } - if slices.Contains(unsupported, tool.Type) { - return errors.New("The configured Subagent profile does not support this tool combination.") - } - } - return nil -} diff --git a/services/agents-api/internal/engine/subagents_test.go b/services/agents-api/internal/engine/subagents_test.go deleted file mode 100644 index 7cf0c7ab1..000000000 --- a/services/agents-api/internal/engine/subagents_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package engine - -import ( - "encoding/json" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func TestSubagentProfilesPreserveQualifiedOperationBoundaries(t *testing.T) { - for _, kind := range []string{"codex", "claude_sdk", "mcode"} { - t.Run(kind, func(t *testing.T) { - profile, ok := (Catalog{}).Lookup(kind) - if !ok { - t.Fatal("profile missing") - } - agent := v1.Agent{Model: "real-model"} - agent.MultiAgent.Enabled = true - for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { - if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: placement}, false); err != nil { - t.Fatal(placement, err) - } - } - // Public functions and MCP remain independently qualified capabilities; new - // child observation support does not automatically admit their combinations. - if kind == "mcode" { - return - } // Its existing tool profile rejects both for every Session. - for _, tool := range []string{`{"type":"function","name":"f"}`, `{"type":"mcp","server_label":"s"}`} { - agent.Tools = []json.RawMessage{json.RawMessage(tool)} - if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err == nil { - t.Fatal("unqualified multi-agent combination accepted", tool) - } - agent.MultiAgent.Enabled = false - if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err != nil { - t.Fatal("single-agent profile changed", err) - } - agent.MultiAgent.Enabled = true - } - }) - } -} diff --git a/services/agents-api/internal/execution/deployment_provider_observations.go b/services/agents-api/internal/execution/deployment_provider_observations.go deleted file mode 100644 index ea2b5c70d..000000000 --- a/services/agents-api/internal/execution/deployment_provider_observations.go +++ /dev/null @@ -1,34 +0,0 @@ -package execution - -import ( - "context" - "encoding/json" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// Observation is strictly after terminal commit. Its pool/lock timeout cannot -// cancel the execution lease or change the already committed public outcome. -func (d *Dispatcher) observeDeploymentProvider(tenantID, sessionID string, turn store.Turn) { - if turn.Status != store.TurnCompleted { - if turn.Status != store.TurnFailed { - return - } - var result Result - if json.Unmarshal(turn.Outcome, &result) != nil || result.ErrorCode != "engine_failed" { - return - } - code, _ := proto.NormalizeEngineFailure(result.EngineErrorCode, nil) - if code == "" || code == "context_length_exceeded" || code == "cyber_policy" { - return - } - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if _, err := d.Store.ObserveDeploymentModelProvider(ctx, tenantID, sessionID, turn.ID); err != nil { - log.Warn(ctx, "Deployment model provider observation unavailable") - } -} diff --git a/services/agents-api/internal/execution/deployment_provider_observations_test.go b/services/agents-api/internal/execution/deployment_provider_observations_test.go deleted file mode 100644 index 1df298ad8..000000000 --- a/services/agents-api/internal/execution/deployment_provider_observations_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package execution - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" -) - -type finishObservationFixture struct { - s *store.Store - writer *store.Store - pool *pgxpool.Pool - tenant string - session store.Session - dispatcher Dispatcher -} - -func newFinishObservationFixture(t *testing.T, maxConnections int32) finishObservationFixture { - t.Helper() - var cfg *pgxpool.Config - s, lease := resetManagerStoreConfig(t, func(c *pgxpool.Config) { - if maxConnections > 0 { - c.MaxConns = maxConnections - } - cfg = c.Copy() - }) - pool, err := pgxpool.NewWithConfig(t.Context(), cfg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(pool.Close) - admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) - provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://fixture.example/v1", APIKey: "fixture-only"} - if _, err = s.SetDeploymentModelProvider(admin, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); err != nil { - t.Fatal(err) - } - snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") - if err != nil { - t.Fatal(err) - } - tenant := uuid.NewString() - model, harness := "fixture-model", "codex" - input := store.CreateSessionInput{Creator: identity.Subject{Kind: "service_account", ID: "fixture"}, Engine: harness, IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"fixture-model"},"environment":{"type":"none"}}`), ModelProvider: snapshot.Provider, ModelProviderSource: "deployment", DeploymentProviderRevision: snapshot.Revision, - ExecutionConfiguration: &v1.SessionExecutionConfiguration{Model: v1.ExecutionSelection{Value: &model, Source: "session"}, Harness: v1.ExecutionSelection{Value: &harness, Source: "deployment"}, ModelProvider: v1.ExecutionProviderSelection{Source: "deployment"}}} - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - return finishObservationFixture{s, lease.Store(), pool, tenant, session, Dispatcher{Store: lease.Store()}} -} -func (f finishObservationFixture) start(t *testing.T) store.InputReceipt { - t.Helper() - receipt, err := f.s.SubmitMessage(t.Context(), f.tenant, f.session.ID, uuid.NewString(), json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"fixture"}]}]}`)) - if err != nil { - t.Fatal(err) - } - if _, err = f.writer.TransitionTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { - t.Fatal(err) - } - return receipt -} -func (f finishObservationFixture) fields(t *testing.T) (*time.Time, *string) { - t.Helper() - rows, err := f.s.ListDeploymentModelProviders(t.Context()) - if err != nil || len(rows) != 1 { - t.Fatal(err) - } - return rows[0].LastUsedAt, rows[0].LastErrorCode -} -func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { - cases := []struct { - name, status, core, native string - unapplied, invalid, used, failed bool - }{ - {name: "success", status: store.TurnCompleted, used: true}, - {name: "provider_failure", status: store.TurnFailed, core: "engine_failed", native: "authentication_error", failed: true}, - {name: "runtime_dominates", status: store.TurnFailed, core: "device_disconnected", native: "authentication_error"}, - {name: "input_policy", status: store.TurnFailed, core: "engine_failed", native: "cyber_policy"}, - {name: "cancelled", status: store.TurnCancelled, core: "engine_failed", native: "authentication_error"}, - {name: "fallback", status: store.TurnCompleted, core: "engine_failed", native: "authentication_error", unapplied: true}, - {name: "invalid_result", status: store.TurnCompleted, invalid: true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f := newFinishObservationFixture(t, 0) - receipt := f.start(t) - result := Result{ErrorCode: tc.core, EngineErrorCode: tc.native, AppliedThrough: receipt.Sequence} - if tc.unapplied { - result.AppliedThrough = 0 - } - if tc.invalid { - result.Error = strings.Repeat("x", 513*1024) - } - turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) - if err != nil { - t.Fatal(err) - } - if tc.unapplied || tc.invalid { - if turn.Status != store.TurnFailed { - t.Fatal("fallback not final", turn.Status) - } - } - used, code := f.fields(t) - if (used != nil) != tc.used || (code != nil) != tc.failed { - t.Fatalf("unexpected observation: used=%v code=%v", used, code) - } - if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_used_at=NULL,last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { - t.Fatal(err) - } - _, err = f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) - if !errors.Is(err, store.ErrTurnConflict) { - t.Fatal("expected completion conflict", err) - } - used, code = f.fields(t) - if used != nil || code != nil { - t.Fatal("failed completion observed") - } - }) - } -} -func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { - for _, mode := range []string{"lock_timeout", "query_failure", "pool_timeout"} { - t.Run(mode, func(t *testing.T) { - // One leased connection plus one ordinary connection gives an independently - // observable pool-acquisition deadline without starving the completion writer. - max := int32(0) - if mode == "pool_timeout" { - max = 1 - } - f := newFinishObservationFixture(t, 0) - receipt := f.start(t) - var cleanup func() - if mode == "lock_timeout" { - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { - t.Fatal(err) - } - cleanup = func() { _ = tx.Rollback(context.Background()) } - } - if mode == "query_failure" { - _, err := f.pool.Exec(t.Context(), `CREATE FUNCTION reject_provider_observation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture secret must never be logged'; END $$; CREATE TRIGGER reject_provider_observation BEFORE UPDATE ON deployment_model_providers FOR EACH ROW EXECUTE FUNCTION reject_provider_observation()`) - if err != nil { - t.Fatal(err) - } - } - if mode == "pool_timeout" { - // A separate single-connection Store owns its leased connection. Only the - // best-effort metadata query needs this exhausted pool after completion. - cfg := f.pool.Config().Copy() - cfg.MaxConns = max - pool, err := pgxpool.NewWithConfig(t.Context(), cfg) - if err != nil { - t.Fatal(err) - } - defer pool.Close() - // Observe directly through a Dispatcher using the saturated pool; the final - // commit below remains on the real existing lease. - conn, err := pool.Acquire(t.Context()) - if err != nil { - t.Fatal(err) - } - defer conn.Release() - turn, err := f.writer.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnCompleted, json.RawMessage(`{}`), "", receipt.Sequence) - if err != nil { - t.Fatal(err) - } - d := Dispatcher{Store: store.New(pool)} - started := time.Now() - d.observeDeploymentProvider(f.tenant, f.session.ID, turn) - if elapsed := time.Since(started); elapsed < 900*time.Millisecond || elapsed > 2*time.Second { - t.Fatal("pool timeout exceeded observation budget", elapsed) - } - if err = f.writer.CheckExecutionOwnership(t.Context()); err != nil { - t.Fatal("observation cancelled lease", err) - } - return - } - if cleanup != nil { - defer cleanup() - } - started := time.Now() - turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", Result{AppliedThrough: receipt.Sequence}, store.TurnCompleted) - elapsed := time.Since(started) - if err != nil || turn.Status != store.TurnCompleted { - t.Fatal("observation changed commit result", err, turn.Status) - } - if elapsed > 2*time.Second || (mode == "lock_timeout" && elapsed < 900*time.Millisecond) { - t.Fatal("observation duration", elapsed) - } - if cleanup != nil { - cleanup() - } - persisted, err := f.s.GetTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID) - if err != nil || persisted.Status != store.TurnCompleted { - t.Fatal("terminal outcome lost", err) - } - used, code := f.fields(t) - if used != nil || code != nil { - t.Fatal("failed observation wrote") - } - if err = f.writer.CheckExecutionOwnership(t.Context()); err != nil { - t.Fatal("observation cancelled execution lease", err) - } - }) - } -} diff --git a/services/agents-api/internal/execution/environment.go b/services/agents-api/internal/execution/environment.go deleted file mode 100644 index d150fc2d3..000000000 --- a/services/agents-api/internal/execution/environment.go +++ /dev/null @@ -1,25 +0,0 @@ -package execution - -import ( - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func resolveExecutionEnvironment(snapshot Snapshot) (string, bool, error) { - if snapshot.Environment != nil { - if snapshot.Environment.Type != "none" || snapshot.Daemon != nil { - return "", false, store.ErrInvalidInput - } - return "", true, nil - } - if snapshot.Daemon == nil { - return "", false, store.ErrInvalidInput - } - workDir := snapshot.Daemon.WorkDir - if workDir != "" && !filepath.IsAbs(workDir) && !strings.HasPrefix(workDir, "~/") { - return "", false, store.ErrInvalidInput - } - return workDir, false, nil -} diff --git a/services/agents-api/internal/execution/environment_test.go b/services/agents-api/internal/execution/environment_test.go deleted file mode 100644 index 6a8ce5019..000000000 --- a/services/agents-api/internal/execution/environment_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package execution - -import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "testing" -) - -func TestExecutionEnvironmentDoesNotDefaultToLocal(t *testing.T) { - cases := []struct { - name string - snapshot Snapshot - dir string - none, invalid bool - }{ - {"public none", Snapshot{Environment: &v1.Environment{Type: "none"}}, "", true, false}, - {"legacy device", Snapshot{Daemon: &DaemonConfig{WorkDir: "/workspace"}}, "/workspace", false, false}, - {"missing", Snapshot{}, "", false, true}, - {"conflicting", Snapshot{Environment: &v1.Environment{Type: "none"}, Daemon: &DaemonConfig{}}, "", false, true}, - {"unsupported", Snapshot{Environment: &v1.Environment{Type: "self_hosted"}}, "", false, true}, - {"relative", Snapshot{Daemon: &DaemonConfig{WorkDir: "workspace"}}, "", false, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - dir, none, err := resolveExecutionEnvironment(c.snapshot) - if (err != nil) != c.invalid || dir != c.dir || none != c.none { - t.Fatal(dir, none, err) - } - }) - } -} diff --git a/services/agents-api/internal/execution/functions.go b/services/agents-api/internal/execution/functions.go deleted file mode 100644 index 6a7e9a9ec..000000000 --- a/services/agents-api/internal/execution/functions.go +++ /dev/null @@ -1,196 +0,0 @@ -package execution - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { - tools := make([]proto.FunctionTool, 0, len(raw)) - names := map[string]bool{} - if len(raw) > 64 { - return nil, errors.New("at most 64 function tools are supported") - } - for _, value := range raw { - var tool struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` - DeferLoading bool `json:"defer_loading"` - } - decoder := json.NewDecoder(bytes.NewReader(value)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&tool); err != nil { - return nil, err - } - var schema map[string]json.RawMessage - if tool.Type != "function" || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { - return nil, errors.New("execution requires unique functions with object schemas") - } - names[tool.Name] = true - tools = append(tools, proto.FunctionTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters, DeferLoading: tool.DeferLoading}) - } - return tools, nil -} - -type functionReply struct { - ack proto.InteractionDecisionAckPayload - err error -} - -type functionExchange struct { - store *store.Store - tenant, session, turn string - kind string - tools []proto.FunctionTool - callID string - reply <-chan functionReply -} - -func (f *functionExchange) record(ctx context.Context, env proto.Envelope) error { - var call proto.FunctionCallPayload - if env.ID != f.turn || env.DecodePayload(&call) != nil { - return errors.New("invalid function callback") - } - declared := false - for _, tool := range f.tools { - declared = declared || tool.Name == call.Name - } - if !declared { - return errors.New("undeclared function callback") - } - err := f.store.RecordFunctionCall(ctx, f.tenant, f.session, f.turn, store.FunctionCall{ - CallID: items.Identity(f.turn, "tool:"+call.CallID), ExecutorCallID: call.CallID, Name: call.Name, Arguments: call.Arguments, - }) - return f.unlessCancelling(ctx, err) -} - -func (f *functionExchange) start(ctx context.Context, peer *gateway.Session) error { - if f.reply != nil || len(f.tools) == 0 { - return nil - } - calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) - if err != nil { - return err - } - for _, call := range calls { - if len(call.Result) == 0 { - continue - } - result, err := functionResult(call) - if err != nil { - return err - } - if err := requireFunctionResultImages(peer, f.kind, result); err != nil { - return err - } - env, err := proto.NewEnvelope(proto.TypeFunctionResult, f.turn, result) - if err != nil { - return err - } - replies := make(chan functionReply, 1) - f.callID, f.reply = call.CallID, replies - go func() { - ack, err := peer.SendAndWaitInteractionAck(ctx, env, result.DeliveryID) - replies <- functionReply{ack: ack, err: err} - }() - return nil - } - return nil -} - -func (f *functionExchange) confirm(ctx context.Context, reply functionReply) error { - id := f.callID - f.callID, f.reply = "", nil - if reply.err != nil { - return reply.err - } - if !reply.ack.Applied { - return fmt.Errorf("function result not applied: %s", reply.ack.ErrorCode) - } - return f.unlessCancelling(ctx, f.store.ConfirmFunctionResult(ctx, f.tenant, f.session, f.turn, id)) -} - -func (f *functionExchange) unlessCancelling(ctx context.Context, err error) error { - if errors.Is(err, store.ErrTurnConflict) { - turn, lookupErr := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) - if lookupErr == nil && !turn.CancelRequestedAt.IsZero() { - return nil - } - } - return err -} - -func (f *functionExchange) complete(ctx context.Context) error { - if len(f.tools) == 0 { - return nil - } - calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) - if err != nil { - return err - } - if len(calls) != 0 { - return errors.New("function calls remain unapplied") - } - turn, err := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) - if err != nil { - return err - } - if turn.Status == store.TurnWaiting { - return errors.New("function turn has not resumed") - } - return nil -} - -func functionResult(call store.FunctionCall) (proto.FunctionResultPayload, error) { - var value struct { - Success *bool `json:"success"` - Output json.RawMessage `json:"output"` - Error *string `json:"error"` - } - if err := json.Unmarshal(call.Result, &value); err != nil { - return proto.FunctionResultPayload{}, err - } - if value.Success == nil { - return proto.FunctionResultPayload{}, errors.New("function result requires success") - } - result := proto.FunctionResultPayload{DeliveryID: "function:" + call.CallID, CallID: call.ExecutorCallID, Success: *value.Success, Content: []proto.InputContent{}} - output := bytes.TrimSpace(value.Output) - if len(output) > 0 && !bytes.Equal(output, []byte("null")) { - if output[0] == '"' { - var text string - if err := json.Unmarshal(output, &text); err != nil { - return result, err - } - result.Content = append(result.Content, proto.InputContent{Type: "input_text", Text: &text}) - } else if err := json.Unmarshal(output, &result.Content); err != nil { - return result, err - } - } - if value.Error != nil { - result.Content = append(result.Content, proto.InputContent{Type: "input_text", Text: value.Error}) - } - return result, result.ValidateContent() -} - -// Check only this result, not ordinary function declarations or text delivery. -func requireFunctionResultImages(peer *gateway.Session, kind string, result proto.FunctionResultPayload) error { - if !(proto.MessageInput{{Content: result.Content}}).HasImages() { - return nil - } - info, found, known := peer.AgentKindStatus(kind) - if !found || !known || !info.Available || !info.Capabilities.FunctionResultImages { - return errors.New("Runtime does not support function result images") - } - return nil -} diff --git a/services/agents-api/internal/execution/functions_test.go b/services/agents-api/internal/execution/functions_test.go deleted file mode 100644 index 5985ef0bf..000000000 --- a/services/agents-api/internal/execution/functions_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package execution - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestFunctionDefinitionsRejectUnsupportedConfiguration(t *testing.T) { - valid := json.RawMessage(`{"type":"function","name":"lookup","description":"Find it","parameters":{"type":"object","properties":{}},"defer_loading":false}`) - tools, err := functionTools([]json.RawMessage{valid}) - if err != nil || len(tools) != 1 || tools[0].Name != "lookup" || tools[0].Description != "Find it" { - t.Fatal(tools, err) - } - for _, raw := range []string{`{"type":"mcp"}`, `{"type":"function","name":"lookup","parameters":null}`, `{"type":"function","name":"lookup","parameters":{},"unknown":true}`} { - if _, err := functionTools([]json.RawMessage{json.RawMessage(raw)}); err == nil { - t.Fatal("unsupported configuration admitted", raw) - } - } - if _, err := functionTools([]json.RawMessage{valid, valid}); err == nil { - t.Fatal("duplicate function names") - } -} - -func TestFunctionResultPreservesCompleteContent(t *testing.T) { - text := func(value string) proto.InputContent { - return proto.InputContent{Type: "input_text", Text: &value} - } - imageURL := "data:image/png;base64,test" - for _, test := range []struct { - raw string - success bool - content []proto.InputContent - }{ - {`{"success":true,"output":""}`, true, []proto.InputContent{text("")}}, - {`{"success":true,"output":null,"error":null}`, true, []proto.InputContent{}}, - {`{"success":false,"error":"failed"}`, false, []proto.InputContent{text("failed")}}, - {`{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,test"},{"type":"input_text","text":""}],"error":"failed"}`, false, []proto.InputContent{text("before"), {Type: "input_image", ImageURL: &imageURL}, text(""), text("failed")}}, - } { - result, err := functionResult(store.FunctionCall{CallID: "public", ExecutorCallID: "native", Result: json.RawMessage(test.raw)}) - if err != nil || result.CallID != "native" || result.DeliveryID != "function:public" || result.Success != test.success || !reflect.DeepEqual(result.Content, test.content) { - t.Fatal(result, err) - } - } - for _, raw := range []string{`{}`, `{"success":null}`, `{"success":true,"output":{}}`, `{"success":true,"output":[{"type":"input_text"}]}`, `{"success":true,"output":[{"type":"input_audio","audio_url":"a"}]}`} { - if _, err := functionResult(store.FunctionCall{Result: json.RawMessage(raw)}); err == nil { - t.Fatal("invalid stored result converted", raw) - } - } -} diff --git a/services/agents-api/internal/execution/mcp.go b/services/agents-api/internal/execution/mcp.go deleted file mode 100644 index ddc4d45e1..000000000 --- a/services/agents-api/internal/execution/mcp.go +++ /dev/null @@ -1,97 +0,0 @@ -package execution - -import ( - "bytes" - "encoding/json" - "errors" - "net/url" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -type executionToolSet struct { - Functions []proto.FunctionTool - MCP []proto.MCPHTTPServer - Search bool - DisableProgrammatic bool -} - -func executionTools(raw []json.RawMessage) (executionToolSet, error) { - functions := make([]json.RawMessage, 0, len(raw)) - var servers []proto.MCPHTTPServer - search := false - disableProgrammatic := false - controls := map[string]bool{} - names := map[string]bool{} - for _, value := range raw { - var kind struct { - Type string `json:"type"` - } - if json.Unmarshal(value, &kind) != nil { - return executionToolSet{}, errors.New("invalid execution tool") - } - if kind.Type == "programmatic_tool_calling" || kind.Type == "web_search" { - if controls[kind.Type] { - return executionToolSet{}, errors.New("execution requires distinct tool controls") - } - controls[kind.Type] = true - if kind.Type == "programmatic_tool_calling" { - var control struct { - Type string `json:"type"` - Enabled *bool `json:"enabled"` - } - decoder := json.NewDecoder(bytes.NewReader(value)) - decoder.DisallowUnknownFields() - if decoder.Decode(&control) != nil || control.Enabled == nil || *control.Enabled { - return executionToolSet{}, errors.New("programmatic tool calling is not qualified for execution") - } - disableProgrammatic = true - } else { - var control struct { - Mode string `json:"mode"` - } - if json.Unmarshal(value, &control) != nil || control.Mode != "disabled" { - return executionToolSet{}, errors.New("only disabled web search is qualified for execution") - } - } - continue - } - if kind.Type == "tool_search" { - decoder := json.NewDecoder(bytes.NewReader(value)) - decoder.DisallowUnknownFields() - if decoder.Decode(&kind) != nil || search { - return executionToolSet{}, errors.New("execution requires one type-only tool_search declaration") - } - search = true - continue - } - if kind.Type != "mcp" { - functions = append(functions, value) - continue - } - var tool v1.MCPTool - decoder := json.NewDecoder(bytes.NewReader(value)) - decoder.DisallowUnknownFields() - if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || (tool.ConnectionOrigin != "service" && tool.ConnectionOrigin != "environment") || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { - return executionToolSet{}, errors.New("unsupported execution MCP configuration") - } - u, err := url.Parse(tool.Transport.ServerURL) - if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { - return executionToolSet{}, errors.New("unsupported execution MCP URL") - } - if tool.AllowedTools != nil { - for _, name := range *tool.AllowedTools { - if name == "" { - return executionToolSet{}, errors.New("invalid execution MCP tool name") - } - } - } - names[tool.ServerLabel] = true - servers = append(servers, proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, - ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) - } - resolved, err := functionTools(functions) - return executionToolSet{Functions: resolved, MCP: servers, Search: search, DisableProgrammatic: disableProgrammatic}, err -} diff --git a/services/agents-api/internal/execution/mcp_credentials.go b/services/agents-api/internal/execution/mcp_credentials.go deleted file mode 100644 index 0611e40f4..000000000 --- a/services/agents-api/internal/execution/mcp_credentials.go +++ /dev/null @@ -1,75 +0,0 @@ -package execution - -import ( - "encoding/json" - "errors" - "net/url" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -// Resolve only the frozen decision. Never search current Vault contents during -// dispatch: a later credential must not change an anonymous or selected server. -func selectedMCPCredentials(snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { - invalid := errors.New("invalid frozen MCP credential binding") - tools := map[string]v1.MCPTool{} - for _, raw := range snapshot.Agent.Tools { - var tool v1.MCPTool - if json.Unmarshal(raw, &tool) != nil { - return nil, invalid - } - if tool.Type == "mcp" { - tools[tool.ServerLabel] = tool - } - } - attached := map[uuid.UUID]bool{} - for _, raw := range snapshot.VaultIDs { - id, err := uuid.Parse(raw) - if err != nil { - return nil, invalid - } - attached[id] = true - } - seen := map[string]bool{} - selected := map[string]store.MCPCredentialBinding{} - for _, binding := range snapshot.MCPCredentials { - tool, exists := tools[binding.ServerLabel] - if !exists || seen[binding.ServerLabel] || tool.Transport.ServerURL != binding.ServerURL { - return nil, invalid - } - seen[binding.ServerLabel] = true - if binding.CredentialID == "" { - if binding.VaultID != "" || binding.AuthType != "" || tool.CredentialID != nil { - return nil, invalid - } - continue - } - vaultID, err := uuid.Parse(binding.VaultID) - if err != nil || !attached[vaultID] || (binding.AuthType != "static_bearer" && binding.AuthType != "mcp_oauth") { - return nil, invalid - } - id, err := uuid.Parse(binding.CredentialID) - if err != nil { - return nil, invalid - } - if tool.CredentialID != nil { - declared, err := uuid.Parse(*tool.CredentialID) - if err != nil || declared != id { - return nil, invalid - } - } - endpoint, err := url.Parse(binding.ServerURL) - if err != nil || endpoint.Scheme != "https" { - return nil, invalid - } - selected[binding.ServerLabel] = binding - } - for label, tool := range tools { - if !seen[label] && (tool.CredentialID != nil || len(snapshot.VaultIDs) > 0 || len(snapshot.MCPCredentials) > 0) { - return nil, invalid - } - } - return selected, nil -} diff --git a/services/agents-api/internal/execution/mcp_credentials_test.go b/services/agents-api/internal/execution/mcp_credentials_test.go deleted file mode 100644 index 1711b72dc..000000000 --- a/services/agents-api/internal/execution/mcp_credentials_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package execution - -import ( - "encoding/json" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestMCPFrozenCredentialAdmission(t *testing.T) { - vault, credential := uuid.NewString(), uuid.NewString() - for _, mode := range []string{"implicit", "explicit", "oauth implicit", "oauth explicit", "anonymous", "missing", "unattached", "wrong URL", "wrong auth", "changed selection", "HTTP", "remote", "self-hosted explicit", "self-hosted implicit", "self-hosted anonymous"} { - t.Run(mode, func(t *testing.T) { - tool := v1.MCPTool{Type: "mcp", ServerLabel: "tools", ConnectionOrigin: "service", Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example/tools"}} - binding := store.MCPCredentialBinding{ServerLabel: "tools", ServerURL: tool.Transport.ServerURL, VaultID: vault, CredentialID: credential, AuthType: "static_bearer"} - snapshot := Snapshot{Agent: v1.Agent{Model: "model"}, Environment: &v1.Environment{Type: "none"}, VaultIDs: []string{vault}} - switch mode { - case "explicit", "oauth explicit", "missing", "changed selection", "self-hosted explicit": - tool.CredentialID = &credential - case "anonymous", "self-hosted anonymous": - binding.VaultID, binding.CredentialID, binding.AuthType = "", "", "" - case "unattached": - snapshot.VaultIDs = nil - case "wrong URL": - binding.ServerURL += "/other" - case "wrong auth": - binding.AuthType = "other" - case "HTTP": - tool.Transport.ServerURL, binding.ServerURL = "http://mcp.example/tools", "http://mcp.example/tools" - case "remote": - snapshot.Daemon = &DaemonConfig{WorkDir: "/tmp"} - } - if strings.HasPrefix(mode, "oauth ") { - binding.AuthType = "mcp_oauth" - } - if strings.HasPrefix(mode, "self-hosted") { - snapshot.Environment = &v1.Environment{Type: "self_hosted", WorkspaceDirectory: "/workspace"} - } - if mode == "changed selection" { - binding.CredentialID = uuid.NewString() - } - snapshot.MCPCredentials = []store.MCPCredentialBinding{binding} - if mode == "missing" { - snapshot.MCPCredentials = nil - } - rawTool, _ := json.Marshal(tool) - snapshot.Agent.Tools = []json.RawMessage{rawTool} - raw, _ := json.Marshal(snapshot) - valid := mode == "implicit" || mode == "explicit" || mode == "anonymous" || strings.HasPrefix(mode, "oauth ") - for _, engine := range []string{"codex", "claude_sdk"} { - supported := valid && (engine == "codex" || !strings.HasPrefix(mode, "self-hosted")) - if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != supported { - t.Fatal("frozen binding profile decision differs", engine, err) - } - } - }) - } -} diff --git a/services/agents-api/internal/execution/message_input.go b/services/agents-api/internal/execution/message_input.go deleted file mode 100644 index 4f3e746b2..000000000 --- a/services/agents-api/internal/execution/message_input.go +++ /dev/null @@ -1,66 +0,0 @@ -package execution - -import ( - "context" - "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func messageInput(raw json.RawMessage) (proto.MessageInput, error) { - var input struct { - Text *string `json:"text"` - Input []v1.InputMessage `json:"input"` - } - if json.Unmarshal(raw, &input) != nil { - return nil, store.ErrInvalidInput - } - var messages proto.MessageInput - if len(input.Input) == 0 && input.Text != nil { - messages = proto.TextInput(*input.Text) - } - for _, message := range input.Input { - if message.Role != "user" { - return nil, store.ErrInvalidInput - } - converted := proto.InputMessage{} - for _, part := range message.Content { - converted.Content = append(converted.Content, proto.InputContent{Type: part.Type, Text: part.Text, ImageURL: part.ImageURL}) - } - messages = append(messages, converted) - } - if messages.Validate() != nil { - return nil, store.ErrInvalidInput - } - return messages, nil -} - -func (d *Dispatcher) initialInput(ctx context.Context, tenant, session, turn string) (proto.MessageInput, int64, error) { - inputs, err := d.Store.ListTurnInputs(ctx, tenant, session, turn, 0, 100) - if err != nil { - return nil, 0, err - } - var messages proto.MessageInput - var through int64 - size := 0 - for _, input := range inputs { - if input.Kind != "message" { - return nil, 0, store.ErrInvalidInput - } - batch, err := messageInput(input.Payload) - if err != nil { - return nil, 0, err - } - if size+len(input.Payload) > 512*1024 && len(messages) > 0 { - break - } - messages = append(messages, batch...) - size += len(input.Payload) - through = input.Sequence - } - if len(messages) == 0 { - return nil, 0, store.ErrInvalidInput - } - return messages, through, nil -} diff --git a/services/agents-api/internal/execution/preparation.go b/services/agents-api/internal/execution/preparation.go deleted file mode 100644 index fdf6a753c..000000000 --- a/services/agents-api/internal/execution/preparation.go +++ /dev/null @@ -1,147 +0,0 @@ -package execution - -import ( - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -var errPreparationFailed = errors.New("runtime preparation failed before admission") - -type preparationRejection struct { - code string - operation string -} - -func (e *preparationRejection) Error() string { return "preparation control rejected: " + e.code } - -type preparedStart struct { - createdAt time.Time - startSentAt time.Time - startObserved bool - readyObserved bool - peer *gateway.Session - requestID string - handle string - executorID string - sub *gateway.Subscription -} - -func newPreparedStart(peer *gateway.Session) (*preparedStart, error) { - id := uuid.NewString() - sub, err := peer.SubscribePreparation(id) - if err != nil { - return nil, err - } - return &preparedStart{peer: peer, requestID: id, sub: sub, createdAt: time.Now()}, nil -} - -func (p *preparedStart) close() { - if p.handle != "" { - _ = send(context.Background(), p.peer, proto.TypeExecutionRelease, p.requestID, proto.ExecutionReleasePayload{Handle: p.handle}) - } - p.peer.UnsubscribePreparation(p.requestID) -} - -func (p *preparedStart) controlStatus(env proto.Envelope) (proto.PreparationStatusPayload, error) { - var status proto.PreparationStatusPayload - if env.Type != proto.TypePreparationStatus || env.ID != p.requestID || env.DecodePayload(&status) != nil { - return status, errors.New("invalid preparation control response") - } - if status.State == "rejected" { - return status, nil - } - if status.Handle == "" || status.Revision == 0 || (p.handle != "" && p.handle != status.Handle) { - return status, errors.New("preparation identity changed") - } - if p.executorID != "" && status.ExecutorID != p.executorID { - return status, errors.New("executor identity changed") - } - p.handle, p.executorID = status.Handle, status.ExecutorID - if status.State == "ready" && status.ExecutorID != "" && !p.readyObserved { - p.readyObserved = true - recordExecutorReadiness(p, status) - } - return status, nil -} - -func (p *preparedStart) observation(env proto.Envelope) (proto.PreparationStatusPayload, error) { - status, err := p.controlStatus(env) - if err != nil { - return status, err - } - if status.State == "rejected" { - return status, &preparationRejection{code: status.ErrorCode, operation: status.Operation} - } - if status.State == "failed" && status.ErrorCode == "preparation_failed" && status.RunID == "" { - return status, errPreparationFailed - } - switch status.State { - case "preparing", "ready", "starting", "started": - return status, nil - default: - return status, errors.New("preparation is no longer available") - } -} - -func (d *Dispatcher) awaitPreparation(ctx context.Context, tenant, session string, pending store.EnvironmentInputReservation, prepared *preparedStart) (store.EnvironmentInputReservation, error) { - tick := time.NewTicker(250 * time.Millisecond) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return pending, ctx.Err() - case <-tick.C: - current, err := d.Store.ExpireEnvironmentInput(ctx, tenant, session, pending.ID) - if err != nil || current.State != store.EnvironmentInputPending { - return current, err - } - case env, ok := <-prepared.sub.Events: - if !ok { - return pending, errors.New("preparation control stream closed") - } - status, err := prepared.observation(env) - if err != nil { - var rejection *preparationRejection - if status.RunID == "" && errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare { - switch rejection.code { - case "invalid_configuration", "unsupported_configuration", "unsupported_preparation": - return pending, errPreparationFailed - } - } - return pending, err - } - if status.State == "ready" && status.RunID == "" && status.ExecutorID != "" { - return pending, nil - } - if status.State != "preparing" { - return pending, errors.New("unexpected preparation state before admission") - } - } - } -} - -func (p *preparedStart) start(ctx context.Context, request proto.PromptRequestPayload) error { - p.startSentAt = time.Now() - return send(ctx, p.peer, proto.TypeExecutionStart, p.requestID, proto.ExecutionStartPayload{Handle: p.handle, ExecutorID: p.executorID, RunID: request.RunID, Input: request.Input}) -} - -func (p *preparedStart) started(env proto.Envelope, runID string) (bool, error) { - status, err := p.observation(env) - if err != nil { - return false, err - } - if status.RunID != runID || (status.State != "starting" && status.State != "started") { - return false, errors.New("unexpected preparation state after admission") - } - if status.State == "started" && !p.startObserved { - p.startObserved = true - recordExecutorStart(p, runID) - } - return status.State == "started", nil -} diff --git a/services/agents-api/internal/execution/provider_operations_fixture_test.go b/services/agents-api/internal/execution/provider_operations_fixture_test.go deleted file mode 100644 index 624068305..000000000 --- a/services/agents-api/internal/execution/provider_operations_fixture_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package execution - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func (*lifecycleOnlySandbox) ProviderOperations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - } -} -func (*lifecycleOnlySandbox) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { - return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { - return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleOnlySandbox) VerifyCredential(context.Context, []sandbox.Reference) error { - return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} -} diff --git a/services/agents-api/internal/execution/recovery_test.go b/services/agents-api/internal/execution/recovery_test.go deleted file mode 100644 index 837096bf1..000000000 --- a/services/agents-api/internal/execution/recovery_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package execution - -import ( - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestExistingSessionRecoveryRequiresVerifiedCapability(t *testing.T) { - for _, engine := range []string{"codex", "claude_sdk", "future-engine"} { - for _, started := range []bool{false, true} { - for _, nativeID := range []string{"", "native"} { - for _, capable := range []bool{false, true} { - wantRecovery := started && nativeID == "" - req, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{ID: "session", Engine: engine}, Snapshot{}, device.KindCapabilities{NativeSessionRecovery: capable}, store.SessionExecutionBinding{HasStartedTurn: started, NativeSessionID: nativeID}) - if wantRecovery && !capable { - if err == nil { - t.Fatal("unverified recovery admitted", engine) - } - continue - } - if err != nil || req.RequireExistingNativeSession != wantRecovery || req.AgentSessionID != nativeID || !req.StrictResume || req.AgentStateKey != "agents-api-session" { - t.Fatalf("engine=%s started=%v id=%s capability=%v request=%+v err=%v", engine, started, nativeID, capable, req, err) - } - } - } - } - } -} diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go deleted file mode 100644 index 414bda0f4..000000000 --- a/services/agents-api/internal/execution/runtime_initialization.go +++ /dev/null @@ -1,164 +0,0 @@ -package execution - -import ( - "context" - "encoding/json" - "errors" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// The leased Worker owns preparation for every Environment. Compute managers -// never run initialization. Bounded concurrent owners prevent one slow machine -// from blocking other environments or resource reclamation. -func (w *Worker) runEnvironmentInitializations(ctx context.Context) error { - active := make(map[string]bool) - done := make(chan string, w.executionConcurrency()) - var running sync.WaitGroup - ctx, stop := context.WithCancel(ctx) - defer func() { stop(); running.Wait() }() - cursor := "" - ticker := time.NewTicker(250 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return ctx.Err() - case id := <-done: - delete(active, id) - case <-ticker.C: - } - rows, err := w.dispatcher.Store.ListEnvironmentInitializations(ctx, cursor) - if err != nil { - return err - } - if len(rows) == 0 { - cursor = "" - } - for _, owner := range rows { - cursor = owner.EnvironmentID - if active[owner.EnvironmentID] { - continue - } - if owner.State == "running" { - // Lost process-local progress cannot prove which side effects ran. - if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{}); err != nil && !errors.Is(err, store.ErrNotFound) { - return err - } - continue - } - if len(active) >= w.executionConcurrency() { - continue - } - peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) - if err != nil { - if errors.Is(err, store.ErrNotFound) || errors.Is(err, gateway.ErrSessionClosed) || errors.Is(err, gateway.ErrDeviceNotRegistered) { - continue - } - return err - } - harness, found, known := peer.AgentKindStatus(owner.Engine) - if !known { - continue - } - if !found || !harness.Available { - if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{Step: store.ProvisioningHarness}); err != nil && !errors.Is(err, store.ErrNotFound) { - return err - } - continue - } - if err := w.dispatcher.Store.ClaimEnvironmentInitialization(ctx, owner); err != nil { - if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrTurnConflict) { - continue - } - return err - } - active[owner.EnvironmentID] = true - running.Add(1) - go func(owner store.EnvironmentInitialization) { - defer running.Done() - w.initializeEnvironment(ctx, owner) - done <- owner.EnvironmentID - }(owner) - } - } -} - -func (w *Worker) initializeEnvironment(ctx context.Context, owner store.EnvironmentInitialization) { - operation, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - failure := store.ProvisioningFailure{} - err := w.prepareEnvironment(operation, owner, &failure) - if err == nil { - err = w.dispatcher.Store.CompleteEnvironmentInitialization(operation, owner) - } - if err != nil { - log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID) - // A later scan settles an unrecorded failure; it never retries the setup. - record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer stop() - _ = w.dispatcher.Store.FailEnvironmentInitialization(record, owner, failure) - } -} - -func (w *Worker) prepareEnvironment(ctx context.Context, owner store.EnvironmentInitialization, failure *store.ProvisioningFailure) error { - environment, err := w.dispatcher.Store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) - if err != nil { - return err - } - var cfg struct { - Files []store.InitialFileMetadata `json:"files"` - } - if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { - return store.ErrInvalidInput - } - setup, err := w.dispatcher.Store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) - if err != nil { - return err - } - peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) - if err != nil { - return err - } - identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID} - operations := setupOperations(setup) - for index := 0; index < len(cfg.Files)+len(operations); index++ { - step, stop := context.WithTimeout(ctx, 2*time.Minute) - err = w.dispatcher.Store.CheckExecutionOwnership(step) - if err == nil { - var currentPeer = peer - currentPeer, err = w.dispatcher.authorizedPeer(step, owner.DeviceID) - if err == nil && currentPeer != peer { - err = errors.New("Runtime connection changed during initialization") - } - } - candidate := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} - if err == nil && index < len(cfg.Files) { - var metadata store.InitialFileMetadata - var body []byte - metadata, body, err = w.dispatcher.Store.ReadInitialEnvironmentFile(step, owner.TenantID, owner.SessionID, index) - if err == nil { - err = installInitialFile(step, peer, identity, metadata, body) - } - } else if err == nil { - command := operations[index-len(cfg.Files)] - candidate = command.provisioningFailure(0) - err = runRuntimeSetup(step, peer, identity, command) - } - stop() - if err != nil { - var confirmed *runtimeStepFailure - if errors.As(err, &confirmed) { - candidate.ExitCode = confirmed.exitCode - *failure = candidate - } - return err - } - } - return nil -} diff --git a/services/agents-api/internal/execution/runtime_observation.go b/services/agents-api/internal/execution/runtime_observation.go deleted file mode 100644 index 671689c4b..000000000 --- a/services/agents-api/internal/execution/runtime_observation.go +++ /dev/null @@ -1,36 +0,0 @@ -package execution - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func (r *runtimeLifecycle) recordObservation(ctx context.Context, owner store.RuntimeAllocation, observed error) { - if owner.NodeID == "" { - return - } - diagnostic := "" - if observed != nil { - switch { - case errors.Is(observed, sandbox.ErrOwnership): - diagnostic = "ownership_mismatch" - case errors.Is(observed, sandbox.ErrNotFound): - diagnostic = "compute_unconfirmed" - if owner.CreateSettled { - diagnostic = "resource_missing" - } - case errors.Is(observed, sandbox.ErrComputeUnconfirmed), errors.Is(observed, sandbox.ErrCommandUnconfirmed): - diagnostic = "compute_unconfirmed" - default: - diagnostic = "provider_unavailable" - if online, err := r.store.RuntimeNodeAvailable(ctx, owner.NodeID); err == nil && !online { - diagnostic = "node_unavailable" - } - } - } - // Reconciliation remains authoritative; diagnostics must not interrupt cleanup. - _ = r.store.RecordRuntimeObservation(ctx, owner, diagnostic) -} diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup.go b/services/agents-api/internal/execution/sandbox_deployment_setup.go deleted file mode 100644 index d929bbece..000000000 --- a/services/agents-api/internal/execution/sandbox_deployment_setup.go +++ /dev/null @@ -1,177 +0,0 @@ -package execution - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// PreparedRuntimeDeployment has completed provider validation without publishing -// a selection. Publish must only update in-memory state and must not fail. -// Selection is the resolved typed configuration returned by preparation. -type PreparedRuntimeDeployment struct { - Config *RuntimeProvider - Publish func(*RuntimeProvider) - Selection *sandbox.Selection - VerifyCredential func(context.Context) error - FenceCredential func(context.Context) (func(), error) -} - -type RuntimeDeploymentPreparer func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) - -// NewDeferredRuntimeProvider enables Web setup for one fixed installation. The -// loader returns nil until selection, then the committed immutable generation. -// Replacement is serialized by the deployment mutation gate and drain flow. -func NewDeferredRuntimeProvider(installationID string, load func(context.Context) (*RuntimeProvider, error), prepare ...RuntimeDeploymentPreparer) *RuntimeProvider { - config := &RuntimeProvider{InstallationID: installationID, loadDeployment: load} - if len(prepare) == 1 { - config.prepareDeployment = prepare[0] - } - return config -} - -func (w *Worker) InitializeSandboxDeployment(ctx context.Context, input store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) { - unlock, err := w.runtimes.lockMutation(ctx) - if err != nil { - return store.RuntimeDeploymentView{}, err - } - defer unlock() - m := w.runtimes - if err := m.store.CheckSandboxDeploymentSetup(ctx, m.setupInstallationID, input); err != nil { - return store.RuntimeDeploymentView{}, err - } - candidate, err := m.prepareCandidate(ctx, input) - if err != nil { - return store.RuntimeDeploymentView{}, err - } - // An idempotent setup retry can arrive after an interrupted replacement. - // It must not reopen admission while that replacement is still draining. - m.mu.Lock() - switching := m.switching - m.mu.Unlock() - if switching { - if err := m.pauseDeployment(ctx); err != nil { - return store.RuntimeDeploymentView{}, err - } - } - result, err := m.store.InitializeSandboxDeployment(ctx, m.setupInstallationID, *candidate.Selection) - if err != nil { - return store.RuntimeDeploymentView{}, err - } - m.publishDeployment(candidate, result) - return result, nil -} - -// ensureDeployment serializes the first configuration read without holding the -// node map lock across database access. All node workers copy this same snapshot. -func (m *runtimeManager) ensureDeployment(parent context.Context) (bool, error) { - if m.loadDeployment == nil { - return true, nil - } - ctx, finish, err := m.enter(parent) - if err != nil { - return false, err - } - defer finish() - select { - case m.setupGate <- struct{}{}: - case <-ctx.Done(): - return false, ctx.Err() - } - defer func() { <-m.setupGate }() - m.mu.Lock() - ready := m.config.Provider != nil - m.mu.Unlock() - if ready { - return true, nil - } - config, err := m.loadDeployment(ctx) - // A missing local provider dependency blocks hosted execution, not the - // administrator's recovery API. The existing scan can load it after repair. - // Storage and ownership failures still stop the execution owner. - if errors.Is(err, ErrExecutionUnavailable) { - return false, nil - } - if err != nil || config == nil { - return false, err - } - if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || config.ProviderKind == "" { - return false, sandbox.ErrInvalid - } - copied, err := validatedRuntimeProvider(config, m.registry) - if err != nil { - return false, err - } - m.mu.Lock() - defer m.mu.Unlock() - if m.switching { - return false, errRuntimeTransition - } - if m.closed || ctx.Err() != nil { - return false, ErrExecutionUnavailable - } - m.config = copied - return true, nil -} - -// Preparation is outside the manager mutex and all database transactions. A -// rejected candidate cannot retire the current generation or its node lanes. -func (m *runtimeManager) prepareCandidate(ctx context.Context, input store.SandboxDeploymentSetupRequest) (PreparedRuntimeDeployment, error) { - if m.prepareDeployment == nil { - return PreparedRuntimeDeployment{}, ErrExecutionUnavailable - } - setup, err := store.SandboxSetupForSelection(m.setupInstallationID, input) - if err != nil { - return PreparedRuntimeDeployment{}, err - } - candidate, err := m.prepareDeployment(ctx, setup) - if err != nil { - return PreparedRuntimeDeployment{}, err - } - config := candidate.Config - if config == nil || config.InstallationID != setup.InstallationID || config.ProviderKind != setup.Provider || config.Mode != setup.Mode || config.CoreURL == "" || config.BackendFingerprint != setup.BackendFingerprint || config.LocalNodeID != "" || config.loadDeployment != nil || config.prepareDeployment != nil { - return PreparedRuntimeDeployment{}, sandbox.ErrInvalid - } - copied, err := validatedRuntimeProvider(config, m.registry) - if err != nil { - return PreparedRuntimeDeployment{}, err - } - if err := ctx.Err(); err != nil { - return PreparedRuntimeDeployment{}, err - } - m.mu.Lock() - closed := m.closed - m.mu.Unlock() - if closed { - return PreparedRuntimeDeployment{}, ErrExecutionUnavailable - } - if candidate.Selection == nil { - candidate.Selection = &input - } - if candidate.Selection.Provider != input.Provider { - return PreparedRuntimeDeployment{}, sandbox.ErrInvalid - } - candidate.Selection.ExpectedGeneration = input.ExpectedGeneration - candidate.Config = &copied - return candidate, nil -} - -// The store commit is the point of no return. Publishing a validated candidate -// is infallible, including when shutdown or request cancellation follows commit. -func (m *runtimeManager) publishDeployment(candidate PreparedRuntimeDeployment, committed store.RuntimeDeploymentView) { - m.mu.Lock() - defer m.mu.Unlock() - config := *candidate.Config - config.Generation, config.AdmissionPaused = committed.Generation, committed.Reset != nil - if m.switching { - m.nodes = make(map[string]*runtimeNode) - } - m.config = config - if candidate.Publish != nil { - candidate.Publish(&config) - } - m.switching = false - m.switchDrained = nil -} diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go deleted file mode 100644 index 39d0d7a52..000000000 --- a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package execution - -import ( - "context" - "errors" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestDeferredSandboxDeploymentLoadsOnceBeforeNodeCreation(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - var selected atomic.Bool - var loads atomic.Int32 - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { - loads.Add(1) - if !selected.Load() { - return nil, nil - } - return configuration, nil - })) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { m.stop(); m.drain() }) - if ready, err := m.ensureDeployment(t.Context()); err != nil || ready { - t.Fatal("empty setup became ready", ready, err) - } - if _, err := m.node("first"); !errors.Is(err, ErrExecutionUnavailable) { - t.Fatal("unconfigured node created", err) - } - selected.Store(true) - var wg sync.WaitGroup - for range 20 { - wg.Add(1) - go func() { - defer wg.Done() - if ready, err := m.ensureDeployment(t.Context()); err != nil || !ready { - t.Errorf("activation failed: %v %v", ready, err) - } - }() - } - wg.Wait() - if loads.Load() != 2 { - t.Fatal("configuration loaded again after selection", loads.Load()) - } - configuration.CoreURL = "https://changed.example/api/v1" - a, err := m.node("first") - if err != nil || a.lifecycle.config.CoreURL != "https://core.example/api/v1" { - t.Fatal("mutable config reached worker", err) - } - m.stop() - if _, err := m.ensureDeployment(t.Context()); !errors.Is(err, ErrExecutionUnavailable) { - t.Fatal("stopped manager activated", err) - } -} - -func TestDeferredSandboxDeploymentShutdownCancelsLoad(t *testing.T) { - entered := make(chan struct{}) - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(uuid.NewString(), func(ctx context.Context) (*RuntimeProvider, error) { - close(entered) - <-ctx.Done() - return nil, ctx.Err() - })) - if err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { _, err := m.ensureDeployment(t.Context()); done <- err }() - <-entered - m.stop() - m.drain() - if err := <-done; !errors.Is(err, context.Canceled) { - t.Fatal("shutdown did not cancel setup load", err) - } -} - -func TestDeferredSandboxProviderFailureKeepsRecoveryAvailable(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - available := false - loadErr := ErrExecutionUnavailable - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { - if !available { - return nil, loadErr - } - return configuration, nil - })) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { m.stop(); m.drain() }) - if nodes, err := m.syncNodes(t.Context()); err != nil || len(nodes) != 0 { - t.Fatal("unavailable provider stopped the manager", err) - } - if _, err := m.node("node"); !errors.Is(err, ErrExecutionUnavailable) { - t.Fatal("unavailable provider admitted execution", err) - } - loadErr = errors.New("storage failure") - if _, err := m.ensureDeployment(t.Context()); !errors.Is(err, loadErr) { - t.Fatal("provider recovery hid a storage failure", err) - } - available = true - if ready, err := m.ensureDeployment(t.Context()); err != nil || !ready { - t.Fatal("repaired provider did not activate", ready, err) - } -} - -func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - rejected := errors.New("candidate provider unavailable") - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil }, - func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) { - return PreparedRuntimeDeployment{}, rejected - })) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - if _, err := m.ensureDeployment(t.Context()); err != nil { - t.Fatal(err) - } - old, err := m.node(uuid.NewString()) - if err != nil { - t.Fatal(err) - } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} - if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { - t.Fatal("candidate rejection was lost", err) - } - if m.config.Generation != 1 || m.config.Provider != config.Provider || m.switching || old.lifecycle.ctx.Err() != nil { - t.Fatal("rejected candidate replaced or drained the active configuration") - } - _, finish, err := m.enter(t.Context()) - if err != nil { - t.Fatal("candidate rejection stopped current execution", err) - } - finish() -} - -func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { - id := uuid.NewString() - entered, release := make(chan struct{}), make(chan struct{}) - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil }, - func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) { - close(entered) - <-release - return PreparedRuntimeDeployment{}, errors.New("rejected") - })) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - done := make(chan struct{}) - go func() { - defer close(done) - _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) - }() - <-entered - stopped := make(chan struct{}) - go func() { m.stop(); close(stopped) }() - select { - case <-stopped: - case <-time.After(time.Second): - close(release) - <-done - t.Fatal("provider validation blocked manager shutdown") - } - close(release) - <-done -} - -func TestCommittedSandboxCandidatePublishesAfterShutdown(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })) - if err != nil { - t.Fatal(err) - } - if err := m.pauseDeployment(t.Context()); err != nil { - t.Fatal(err) - } - config := &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - var published *RuntimeProvider - candidate := PreparedRuntimeDeployment{Config: config, Publish: func(value *RuntimeProvider) { published = value }} - m.stop() - m.publishDeployment(candidate, store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b", Reset: &store.SandboxResetView{}}) - m.drain() - if m.config.Generation != 2 || !m.config.AdmissionPaused || published == nil || published.Generation != 2 || !published.AdmissionPaused || m.switching { - t.Fatal("committed candidate was lost during shutdown") - } - if _, _, err := m.enter(t.Context()); !errors.Is(err, ErrExecutionUnavailable) { - t.Fatal("publication reopened a closed manager", err) - } -} diff --git a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go b/services/agents-api/internal/execution/sandbox_deployment_switch_test.go deleted file mode 100644 index 19e900a3c..000000000 --- a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package execution - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestSandboxManagerSwitchDrainsBeforeDirectActivation(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - if _, err := m.ensureDeployment(t.Context()); err != nil { - t.Fatal(err) - } - old, err := m.node(uuid.NewString()) - if err != nil { - t.Fatal(err) - } - _, finish, err := m.enter(t.Context()) - if err != nil { - t.Fatal(err) - } - paused := make(chan error, 1) - go func() { paused <- m.pauseDeployment(t.Context()) }() - select { - case <-old.lifecycle.ctx.Done(): - case <-time.After(time.Second): - finish() - t.Fatal("old lifecycle not cancelled") - } - if _, err := m.node(uuid.NewString()); !errors.Is(err, errRuntimeTransition) { - finish() - t.Fatal("transition admitted new lifecycle", err) - } - select { - case err := <-paused: - finish() - t.Fatal("switch skipped active caller", err) - default: - } - finish() - select { - case err := <-paused: - if err != nil { - t.Fatal(err) - } - case <-time.After(time.Second): - t.Fatal("switch drain blocked") - } - config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - if err := m.activateDeployment(t.Context(), store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"}); err != nil { - t.Fatal(err) - } - if _, err := m.node(uuid.NewString()); !errors.Is(err, ErrExecutionUnavailable) { - t.Fatal("cloud created physical lifecycle", err) - } - current, err := m.node("") - if err != nil || current.lifecycle.config.Generation != 2 { - t.Fatal("cloud lifecycle not activated", err) - } - if len(m.nodes) != 1 { - t.Fatal("old lifecycle retained after activation") - } -} - -func TestSandboxManagerFailedActivationStaysPaused(t *testing.T) { - id := uuid.NewString() - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, errors.New("provider unavailable") })) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - if err := m.pauseDeployment(t.Context()); err != nil { - t.Fatal(err) - } - if err := m.activateDeployment(t.Context(), store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"}); err == nil { - t.Fatal("failed provider activated") - } - if _, _, err := m.enter(t.Context()); !errors.Is(err, errRuntimeTransition) { - t.Fatal("failed activation reopened work", err) - } -} - -func TestSandboxManagerCancelledSwitchCannotResumeBeforeDrain(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) - if err != nil { - t.Fatal(err) - } - if _, err := m.ensureDeployment(t.Context()); err != nil { - t.Fatal(err) - } - _, finish, err := m.enter(t.Context()) - if err != nil { - t.Fatal(err) - } - released := false - defer func() { - if !released { - finish() - } - m.stop() - m.drain() - }() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) - if err := m.pauseDeployment(ctx); !errors.Is(err, context.DeadlineExceeded) { - cancel() - t.Fatal("initial pause did not wait for old caller", err) - } - cancel() - m.mu.Lock() - originalDrain := m.switchDrained - m.mu.Unlock() - config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} - expected := store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"} - ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) - if err := m.activateDeployment(ctx, expected); !errors.Is(err, context.DeadlineExceeded) { - cancel() - t.Fatal("resume bypassed outstanding drain", err) - } - cancel() - m.mu.Lock() - sameDrain := m.switchDrained == originalDrain - generation := m.config.Generation - m.mu.Unlock() - if !sameDrain || generation != 1 { - t.Fatal("retry replaced drain or activated configuration") - } - finish() - released = true - if err := m.activateDeployment(t.Context(), expected); err != nil { - t.Fatal("drained generation did not resume", err) - } -} - -func TestSandboxActivationCannotBypassOutstandingDrain(t *testing.T) { - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - id := uuid.NewString() - m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, - func(context.Context) (*RuntimeProvider, error) { return nil, nil }, - func(_ context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { - return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}}, nil - })) - if err != nil { - t.Fatal(err) - } - _, finish, err := m.enter(t.Context()) - if err != nil { - t.Fatal(err) - } - defer func() { finish(); m.stop(); m.drain() }() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) - if err := m.pauseDeployment(ctx); !errors.Is(err, context.DeadlineExceeded) { - cancel() - t.Fatal("pause did not retain an outstanding caller", err) - } - cancel() - ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) - defer cancel() - err = m.activateDeployment(ctx, store.RuntimeDeploymentView{InstallationID: id, Generation: 1, Provider: "e2b", Mode: "direct"}) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatal("activation bypassed the unfinished drain", err) - } -} diff --git a/services/agents-api/internal/execution/sandbox_generations_test.go b/services/agents-api/internal/execution/sandbox_generations_test.go deleted file mode 100644 index ecc457d1d..000000000 --- a/services/agents-api/internal/execution/sandbox_generations_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package execution - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) { - s, lease := resetManagerStore(t) - writer := lease.Store() - id := uuid.NewString() - if err := writer.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} - input.Resources.CPUs = 2 - input.Resources.MemoryMiB = 2048 - if _, err := writer.InitializeSandboxDeployment(t.Context(), id, input); err != nil { - t.Fatal(err) - } - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - provider := hub.Proxy(uuid.NewString(), "docker", 1) - verifyCalls, published, fenced, released := 0, 0, 0, 0 - var rejectAt int - var rejection error = e2b.ErrTeamMismatch - config := NewDeferredRuntimeProvider(id, func(ctx context.Context) (*RuntimeProvider, error) { - setup, err := s.GetSandboxSetup(ctx) - if err != nil { - return nil, err - } - return &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}, nil - }, func(ctx context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { - return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}, - VerifyCredential: func(context.Context) error { - verifyCalls++ - if verifyCalls == rejectAt { - return rejection - } - return nil - }, - FenceCredential: func(context.Context) (func(), error) { fenced++; return func() { released++ }, nil }, Publish: func(*RuntimeProvider) { published++ }}, nil - }) - m, err := newRuntimeManager(writer, gateway.NewRegistry(), config) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - if _, err = m.ensureDeployment(t.Context()); err != nil { - t.Fatal(err) - } - old, err := m.node("") - if err != nil { - t.Fatal(err) - } - worker := &Worker{runtimes: m} - input.E2B.APIKey = "candidate-key" - request := store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1} - audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "test", RequestID: "test", TraceID: "test"}) - for _, failure := range []string{"preliminary", "final", "unanchored legacy or revoked committed key", "unknown ownership", "commit"} { - verifyCalls = 0 - rejectAt = 0 - ctx := audit - switch failure { - case "preliminary": - rejectAt = 1 - case "final": - rejectAt = 2 - case "unanchored legacy or revoked committed key": - rejectAt = 1 - rejection = &store.SandboxResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"} - case "unknown ownership": - rejectAt = 1 - rejection = e2b.ErrRequestUnconfirmed - case "commit": - ctx = t.Context() - } - if _, err := worker.UpdateSandboxDeployment(ctx, request); err == nil { - t.Fatal("failure published", failure) - } - committed, err := s.GetSandboxSetup(t.Context()) - if err != nil || committed.Generation != 1 || committed.E2B.APIKey != "old-key" || published != 0 || fenced != released { - t.Fatal("partial credential publication", failure, err) - } - current, err := m.node("") - if err != nil || current != old || m.switching { - t.Fatal("online failure drained an owned lifecycle", err) - } - } - verifyCalls = 0 - rejectAt = 0 - result, err := worker.UpdateSandboxDeployment(audit, request) - if err != nil || result.Generation != 2 || verifyCalls != 2 || published != 1 || fenced != released { - t.Fatal(result, verifyCalls, published, err) - } - current, err := m.node("") - if err != nil || current != old { - t.Fatal("online commit replaced lifecycle", err) - } - before := verifyCalls - if _, err := worker.UpdateSandboxDeployment(audit, request); err == nil { - t.Fatal("stale replay accepted") - } else { - var stale *store.SandboxGenerationStaleError - if !errors.As(err, &stale) { - t.Fatal(err) - } - } - if verifyCalls != before { - t.Fatal("stale request reached E2B") - } -} diff --git a/services/agents-api/internal/execution/sandbox_reset.go b/services/agents-api/internal/execution/sandbox_reset.go deleted file mode 100644 index 13e8e6f9a..000000000 --- a/services/agents-api/internal/execution/sandbox_reset.go +++ /dev/null @@ -1,120 +0,0 @@ -package execution - -import ( - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func (w *Worker) StartSandboxReset(ctx context.Context, input store.SandboxResetRequest) (store.RuntimeDeploymentView, error) { - unlock, err := w.runtimes.lockMutation(ctx) - if err != nil { - return store.RuntimeDeploymentView{}, err - } - defer unlock() - return w.runtimes.store.StartSandboxReset(ctx, w.runtimes.setupInstallationID, input) -} - -func (w *Worker) CancelSandboxReset(ctx context.Context, generation uint64) (store.RuntimeDeploymentView, error) { - unlock, err := w.runtimes.lockMutation(ctx) - if err != nil { - return store.RuntimeDeploymentView{}, err - } - defer unlock() - return w.runtimes.store.CancelSandboxReset(ctx, w.runtimes.setupInstallationID, generation) -} - -// resetStep runs only on the manager's uncounted coordinator loop. It must never -// enter m.active, hold a Session/deployment transaction, or call a provider while -// waiting for a deployment drain. Each page has both a row and time bound. -func (m *runtimeManager) resetStep(parent context.Context) error { - if m.loadDeployment == nil { - return nil - } - ctx, cancel := context.WithTimeout(parent, 5*time.Second) - defer cancel() - return m.resetPage(parent, ctx) -} - -func (m *runtimeManager) resetPage(parent, ctx context.Context) error { - unlock, err := m.lockMutation(ctx) - if err != nil { - if ctx.Err() != nil && parent.Err() == nil { - return nil - } - return err - } - defer unlock() - if err := m.store.AdvanceSandboxResetDeadline(ctx); err != nil { - return err - } - current, err := m.store.GetRuntimeDeployment(ctx) - if err != nil { - return err - } - if current.Reset == nil { - m.resetCursor = "" - m.resetRequestedAt = time.Time{} - return nil - } - if !current.Reset.RequestedAt.Equal(m.resetRequestedAt) { - m.resetCursor = "" - m.resetRequestedAt = current.Reset.RequestedAt - } - candidates, err := m.store.ListSandboxResetSessions(ctx, m.resetCursor, current.Reset.Clear == "force") - if err != nil { - return err - } - for _, candidate := range candidates { - if ctx.Err() != nil { - return nil - } - _, err := m.store.ArchiveSandboxResetSession(ctx, candidate.TenantID, candidate.SessionID, current.Generation, current.Reset.RequestedAt) - m.resetCursor = candidate.SessionID - if err != nil && !errors.Is(err, store.ErrSandboxResetSessionBusy) && !errors.Is(err, store.ErrNotFound) { - // Do not log a provider body, request, credential or stored provenance. - log.Warn(ctx, "Sandbox reset archive remains pending", "session_id", candidate.SessionID) - } - } - if len(candidates) < 32 { - m.resetCursor = "" - } - if ctx.Err() != nil { - return nil - } - current, err = m.store.GetRuntimeDeployment(ctx) - if err != nil { - return err - } - if current.Reset == nil || current.Resources.Allocations != 0 || current.Resources.Pending != 0 { - return nil - } - if err := m.pauseDeployment(ctx); err != nil { - if recovery := m.restoreCommittedDeployment(); recovery != nil { - return errors.Join(err, recovery) - } - if parent.Err() == nil && ctx.Err() != nil && errors.Is(err, ctx.Err()) { - // A bounded page may expire during drain. Successful owner-context - // recovery keeps this durable reset available for the next tick. - return nil - } - return err - } - committed, err := m.store.CompleteSandboxReset(ctx, m.setupInstallationID, current.Generation, current.Reset.RequestedAt) - if err != nil { - recovery := m.restoreCommittedDeployment() - if recovery != nil { - return errors.Join(err, recovery) - } - // The transaction rechecks counts after the drain. A losing recheck - // leaves the durable clear intact for the next owner tick. - log.Warn(parent, "Sandbox reset completion remains pending", "generation", current.Generation) - return nil - } - m.publishEmptyDeployment(committed) - m.resetCursor = "" - return nil -} diff --git a/services/agents-api/internal/execution/sandbox_reset_test.go b/services/agents-api/internal/execution/sandbox_reset_test.go deleted file mode 100644 index 09249ba5b..000000000 --- a/services/agents-api/internal/execution/sandbox_reset_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package execution - -import ( - "bytes" - "context" - "database/sql" - "os" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/pgx/v5/stdlib" - "github.com/pressly/goose/v3" -) - -// The execution lease is database-scoped, so this manager test owns a database. -func resetManagerStore(t *testing.T) (*store.Store, *store.ExecutionLease) { - t.Helper() - return resetManagerStoreConfig(t, nil) -} - -func resetManagerStoreConfig(t *testing.T, configure func(*pgxpool.Config)) (*store.Store, *store.ExecutionLease) { - t.Helper() - url := os.Getenv("OAC_TEST_DATABASE_URL") - if url == "" { - t.Skip("OAC_TEST_DATABASE_URL is required") - } - admin, err := pgxpool.New(t.Context(), url) - if err != nil { - t.Fatal(err) - } - t.Cleanup(admin.Close) - name := "oac_reset_" + uuid.NewString()[:8] + "_tests" - quoted := pgx.Identifier{name}.Sanitize() - if _, err := admin.Exec(t.Context(), "CREATE DATABASE "+quoted); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if _, err := admin.Exec(ctx, "DROP DATABASE "+quoted+" WITH (FORCE)"); err != nil { - t.Error(err) - } - }) - cfg := admin.Config().Copy() - cfg.ConnConfig.Database = name - db := sql.OpenDB(stdlib.GetConnector(*cfg.ConnConfig)) - provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) - if err != nil { - t.Fatal(err) - } - _, err = provider.Up(t.Context()) - _ = db.Close() - if err != nil { - t.Fatal(err) - } - if configure != nil { - configure(cfg) - } - pool, err := pgxpool.NewWithConfig(t.Context(), cfg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(pool.Close) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) - if err != nil { - t.Fatal(err) - } - s := store.NewWithCredentialCipher(pool, cipher) - lease, err := s.AcquireExecutionLease(t.Context()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = lease.Close(context.Background()) }) - return s, lease -} - -func TestSandboxResetPageTimeoutRecoversCommittedOwner(t *testing.T) { - s, lease := resetManagerStore(t) - w := lease.Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} - selection.Resources.CPUs = 2 - selection.Resources.MemoryMiB = 2048 - if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { - t.Fatal(err) - } - hub := node.NewHub(node.HubOptions{}) - defer hub.Close() - loads := 0 - config := NewDeferredRuntimeProvider(id, func(ctx context.Context) (*RuntimeProvider, error) { - if ctx.Err() != nil { - t.Error("recovery inherited cancelled page") - } - loads++ - setup, err := s.GetSandboxSetup(ctx) - if err != nil || setup.Provider == "" { - return nil, err - } - return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Mode: setup.Mode, Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}, nil - }) - m, err := newRuntimeManager(w, gateway.NewRegistry(), config) - if err != nil { - t.Fatal(err) - } - defer func() { m.stop(); m.drain() }() - if _, err := m.ensureDeployment(t.Context()); err != nil { - t.Fatal(err) - } - old, err := m.node("") - if err != nil { - t.Fatal(err) - } - _, finish, err := m.enter(t.Context()) - if err != nil { - t.Fatal(err) - } - released := false - defer func() { - if !released { - finish() - } - }() - audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "reset-test", ActorLabel: "operator", RequestID: "request", TraceID: "trace"}) - reset, err := w.StartSandboxReset(audit, id, store.SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}) - if err != nil { - t.Fatal(err) - } - page, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { done <- m.resetPage(t.Context(), page) }() - select { - case <-old.lifecycle.ctx.Done(): - case <-time.After(5 * time.Second): - t.Fatal("reset never reached drain") - } - cancel() // Expire this page only after its drain barrier is established. - finish() - released = true - select { - case err := <-done: - if err != nil { - t.Fatal("recoverable page cancellation stopped owner", err) - } - case <-time.After(5 * time.Second): - t.Fatal("owner recovery blocked") - } - current, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || current.Reset == nil || current.Generation != 1 || !current.Reset.RequestedAt.Equal(reset.Reset.RequestedAt) { - t.Fatal("page timeout lost durable reset", current, err) - } - if loads < 2 { - t.Fatal("committed provider was not restored") - } - _, finishNext, err := m.enter(t.Context()) - if err != nil { - t.Fatal("recovery left barrier closed", err) - } - finishNext() - if err := m.resetStep(t.Context()); err != nil { - t.Fatal(err) - } - current, err = s.GetRuntimeDeployment(t.Context()) - if err != nil || current.Reset != nil || current.Provider != "" || current.Generation != 2 { - t.Fatal("next tick did not finish", current, err) - } -} diff --git a/services/agents-api/internal/items/command_output_test.go b/services/agents-api/internal/items/command_output_test.go deleted file mode 100644 index a737b509d..000000000 --- a/services/agents-api/internal/items/command_output_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package items - -import ( - "encoding/json" - "reflect" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func TestCommandOutputKeepsIdentityAndReplacesDraftAtCompletion(t *testing.T) { - previous := v1.Item{ID: Identity(testTurn, "tool:cmd"), TurnID: testTurn, Type: "command_execution", Command: "run", Status: "in_progress"} - for _, delta := range []string{"same\n", "same\n", "结束\n"} { - raw, _ := json.Marshal(map[string]string{"id": "cmd", "delta": delta}) - updates, err := Project(testTurn, "command_output", 1, raw) - if err != nil { - t.Fatal(err) - } - before, _ := json.Marshal(previous) - merged := mustMerge(t, updates[0], previous) - after, _ := json.Marshal(previous) - if string(before) != string(after) || *updates[0].CommandOutputDelta != delta || merged.ID != previous.ID || merged.Command != "run" { - t.Fatal("delta merge changed its input or command identity") - } - previous = merged - } - if string(encoded(previous.Output)) != `"same\nsame\n结束\n"` { - t.Fatal(string(encoded(previous.Output))) - } - for _, snapshot := range []struct{ raw, want string }{ - {``, `"same\nsame\n结束\n"`}, {`""`, `""`}, {`"authoritative"`, `"authoritative"`}, - } { - final := previous - final.Status, final.Output = "completed", nil - if snapshot.raw != "" { - final.Output = json.RawMessage(snapshot.raw) - } - merged := mustMerge(t, Update{Item: final}, previous) - if string(encoded(merged.Output)) != snapshot.want { - t.Fatal(string(encoded(merged.Output))) - } - late := "late" - got := mustMerge(t, Update{Item: final, CommandOutputDelta: &late}, merged) - if !reflect.DeepEqual(got, merged) { - t.Fatal("late output changed completed command") - } - } -} - -func TestCommandOutputRequiresMatchingCommand(t *testing.T) { - updates, err := Project(testTurn, "command_output", 1, []byte(`{"id":"cmd","delta":"text"}`)) - if err != nil { - t.Fatal(err) - } - for _, previous := range []v1.Item{ - {}, - {ID: updates[0].Item.ID, TurnID: testTurn, Type: "mcp_call"}, - {ID: updates[0].Item.ID, TurnID: "other", Type: "command_execution"}, - {ID: updates[0].Item.ID, TurnID: testTurn, Type: "command_execution", Status: "in_progress", Output: json.RawMessage(`{}`)}, - } { - if _, err := Merge(updates[0], previous); err == nil { - t.Fatal("invalid prior command accepted") - } - } - for _, raw := range []string{`{}`, `{"id":"cmd","delta":null}`, `{"id":"cmd","delta":""}`, `{"id":"cmd","delta":7}`, `{"delta":"text"}`} { - if _, err := Project(testTurn, "command_output", 1, []byte(raw)); err == nil { - t.Fatal("invalid delta accepted", raw) - } - } -} diff --git a/services/agents-api/internal/items/inputs.go b/services/agents-api/internal/items/inputs.go deleted file mode 100644 index 6497708ca..000000000 --- a/services/agents-api/internal/items/inputs.go +++ /dev/null @@ -1,44 +0,0 @@ -package items - -import ( - "encoding/json" - "strconv" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func inputMessages(turn string, sequence int64, raw json.RawMessage) []Update { - var p struct { - Text *string `json:"text"` - Input []struct { - Role string `json:"role"` - Content []v1.ItemContent `json:"content"` - } `json:"input"` - } - // Internal admission predates the public schema and accepts arbitrary objects. - if err := json.Unmarshal(raw, &p); err != nil { - return nil - } - key := "input:" + strconv.FormatInt(sequence, 10) - if p.Text != nil { - return []Update{{Item: message(turn, key, "user", *p.Text, "completed")}} - } - var updates []Update - for i, input := range p.Input { - if input.Role != "user" || len(input.Content) == 0 { - continue - } - valid := true - for _, c := range input.Content { - if !((c.Type == "input_text" && c.Text != nil) || (c.Type == "input_image" && c.ImageURL != "")) { - valid = false - } - } - if !valid { - continue - } - item := v1.Item{ID: Identity(turn, key+":"+strconv.Itoa(i)), TurnID: turn, Type: "message", Status: "completed", Role: "user", Content: input.Content} - updates = append(updates, Update{Item: item}) - } - return updates -} diff --git a/services/agents-api/internal/items/messages_test.go b/services/agents-api/internal/items/messages_test.go deleted file mode 100644 index a7ee296ba..000000000 --- a/services/agents-api/internal/items/messages_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package items - -import ( - "encoding/json" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -const testTurn = "bff31a40-9a63-4a49-aebe-89dfe9dd5268" - -func TestMessageSnapshotsReplaceDeltasAndDoNotRegress(t *testing.T) { - var previous v1.Item - for _, event := range []struct{ kind, body string }{ - {"output_message", `{"id":"native","status":"in_progress","phase":"commentary"}`}, - {"delta", `{"item_id":"native","delta":"draft"}`}, - {"output_message", `{"id":"native","status":"completed","phase":"final_answer","text":"Revised answer"}`}, - {"delta", `{"item_id":"native","delta":"late"}`}, - {"output_message", `{"id":"native","status":"in_progress"}`}, - } { - updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) - if err != nil { - t.Fatal(err) - } - previous = mustMerge(t, updates[0], previous) - } - if previous.Status != "completed" || previous.Phase != "final_answer" || *previous.Content[0].Text != "Revised answer" { - t.Fatalf("%+v", previous) - } - raw, _ := json.Marshal(previous) - if strings.Contains(string(raw), "native") { - t.Fatal("native identity leaked") - } -} - -func TestLegacyDoneDoesNotConfirmSuccessfulAnswer(t *testing.T) { - var previous v1.Item - for _, event := range []struct{ kind, body string }{ - {"delta", `{"delta":"partial answer"}`}, - {"error", `{"error":"provider failure"}`}, - {"done", `{"content":"provider failure"}`}, - {"execution_failed", `{"done":{"content":"provider failure"}}`}, - } { - updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) - if err != nil { - t.Fatal(err) - } - for _, update := range updates { - previous = mustMerge(t, update, previous) - } - } - if previous.Status != "in_progress" || *previous.Content[0].Text != "partial answer" { - t.Fatal(previous) - } - updates, err := Project(testTurn, "execution_completed", 1, []byte(`{"done":{"content":"complete answer"}}`)) - if err != nil { - t.Fatal(err) - } - previous = mustMerge(t, updates[0], previous) - if previous.Status != "completed" || *previous.Content[0].Text != "complete answer" { - t.Fatal(previous) - } -} - -func TestMergePreservesIncomingDeltasAndPreviousSnapshots(t *testing.T) { - var previous v1.Item - fragments := []string{"go ", "go ", "结束"} - for i, fragment := range fragments { - update := Update{Item: message(testTurn, "message:native", "assistant", fragment, "in_progress"), AppendText: true} - before, _ := json.Marshal(previous) - merged := mustMerge(t, update, previous) - after, _ := json.Marshal(previous) - if string(before) != string(after) { - t.Fatal("merge mutated the previous snapshot") - } - if *update.Item.Content[0].Text != fragment { - t.Fatalf("incoming delta changed: got %q, want %q", *update.Item.Content[0].Text, fragment) - } - if *merged.Content[0].Text != strings.Join(fragments[:i+1], "") { - t.Fatalf("accumulated text changed: %+v", merged) - } - previous = merged - } -} - -func mustMerge(t *testing.T, update Update, previous v1.Item) v1.Item { - t.Helper() - item, err := Merge(update, previous) - if err != nil { - t.Fatal(err) - } - return item -} diff --git a/services/agents-api/internal/runtimeenrollment/connection.go b/services/agents-api/internal/runtimeenrollment/connection.go deleted file mode 100644 index f143661de..000000000 --- a/services/agents-api/internal/runtimeenrollment/connection.go +++ /dev/null @@ -1,136 +0,0 @@ -package runtimeenrollment - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/url" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type ConnectionStore interface { - AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) - GetEnvironment(context.Context, string, string) (store.Environment, error) - GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) - GetDeviceCredential(context.Context, string) (device.Credential, bool, error) -} - -// ConnectionHandler observes an existing binding without enrollment or execution. -// Executor authority never grants access to the public Session API. -func ConnectionHandler(s ConnectionStore, registry *gateway.Registry) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-store") - fail := func(status int) { http.Error(w, http.StatusText(status), status) } - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - fail(http.StatusMethodNotAllowed) - return - } - authorization := strings.Fields(r.Header.Get("Authorization")) - if len(authorization) != 2 || !strings.EqualFold(authorization[0], "Bearer") { - fail(http.StatusUnauthorized) - return - } - query, err := url.ParseQuery(r.URL.RawQuery) - if err != nil || len(query) != 1 || len(query["environment_id"]) != 1 || query.Get("environment_id") == "" { - fail(http.StatusBadRequest) - return - } - environment := query.Get("environment_id") - digest := device.HashCredential(authorization[1]) - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - connected, err := RuntimeConnected(ctx, s, registry, environment, digest) - switch { - case errors.Is(err, store.ErrNotFound): - fail(http.StatusUnauthorized) - case errors.Is(err, store.ErrDeviceBindingConflict): - fail(http.StatusConflict) - case err != nil: - fail(http.StatusServiceUnavailable) - default: - status := "disconnected" - if connected { - status = "connected" - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(struct { - EnvironmentID string `json:"environment_id"` - Status string `json:"status"` - }{environment, status}) - } - }) -} - -// RuntimeConnected observes current executor authority and a matching open peer. -// It rechecks authority after the peer; callers must not supply a stale transaction. -func RuntimeConnected(ctx context.Context, s ConnectionStore, registry *gateway.Registry, environment, digest string) (bool, error) { - tenant, err := s.AuthenticateEnvironmentExecutor(ctx, environment, digest) - if err != nil { - return false, err - } - current, err := s.GetEnvironment(ctx, tenant, environment) - if err != nil { - return false, err - } - if current.Status == "failed" || current.Status == "expired" { - return false, store.ErrNotFound - } - bound, err := s.GetSessionDevice(ctx, tenant, current.SessionID) - if errors.Is(err, store.ErrNotFound) { - return false, nil - } - if err != nil { - return false, err - } - if bound.EnvironmentID != environment { - return false, store.ErrDeviceBindingConflict - } - credential, found, err := s.GetDeviceCredential(ctx, bound.ID) - if err != nil { - return false, err - } - if !found { - return false, store.ErrNotFound - } - if credential.CredentialHash != digest { - return false, store.ErrDeviceBindingConflict - } - if registry == nil { - return false, nil - } - peer, err := registry.LookupDevice(bound.ID) - if errors.Is(err, gateway.ErrDeviceNotRegistered) { - return false, nil - } - if err != nil { - return false, err - } - if current.Status != "connected" || peer.IsClosed() || !peer.AuthenticatedWith(digest) { - return false, nil - } - // Recheck authority after reading the socket; rotation/revocation never inherits - // the connected observation of a socket authenticated with the former key. - if _, err = s.AuthenticateEnvironmentExecutor(ctx, environment, digest); err != nil { - return false, err - } - // The executor key can remain valid while the device itself is revoked. - // Recheck the shared authority view too, including Environment retirement. - credential, found, err = s.GetDeviceCredential(ctx, bound.ID) - if err != nil { - return false, err - } - if !found { - return false, store.ErrNotFound - } - if credential.CredentialHash != digest { - return false, store.ErrDeviceBindingConflict - } - return !peer.IsClosed() && peer.AuthenticatedWith(digest), nil -} diff --git a/services/agents-api/internal/runtimeenrollment/connection_test.go b/services/agents-api/internal/runtimeenrollment/connection_test.go deleted file mode 100644 index 4b02ff7e3..000000000 --- a/services/agents-api/internal/runtimeenrollment/connection_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package runtimeenrollment - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/gorilla/websocket" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type connectionStub struct { - err error - calls int -} - -func (s *connectionStub) AuthenticateEnvironmentExecutor(_ context.Context, environment, digest string) (string, error) { - s.calls++ - if environment != "environment" || digest != device.HashCredential("test-key") { - return "", store.ErrNotFound - } - return "tenant", s.err -} -func (*connectionStub) GetEnvironment(context.Context, string, string) (store.Environment, error) { - return store.Environment{ID: "environment", SessionID: "session", Status: "pending"}, nil -} -func (*connectionStub) GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) { - return store.ExecutionDevice{}, store.ErrNotFound -} -func (*connectionStub) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { - panic("unbound lookup") -} - -func TestConnectionReadContract(t *testing.T) { - for _, tc := range []struct { - method, query, bearer string - err error - code, calls int - }{ - {"GET", "environment_id=environment", "Bearer test-key", nil, 200, 1}, - {"GET", "environment_id=environment", "", nil, 401, 0}, - {"POST", "environment_id=environment", "Bearer test-key", nil, 405, 0}, - {"GET", "environment_id=environment&environment_id=other", "Bearer test-key", nil, 400, 0}, - {"GET", "environment_id=environment&other=1", "Bearer test-key", nil, 400, 0}, - {"GET", "environment_id=%zz", "Bearer test-key", nil, 400, 0}, - {"GET", "environment_id=environment", "Bearer test-key", store.ErrNotFound, 401, 1}, - {"GET", "environment_id=environment", "Bearer test-key", errors.New("private detail"), 503, 1}, - } { - s := &connectionStub{err: tc.err} - req := httptest.NewRequest(tc.method, "/api/v1/agent-daemon/connection?"+tc.query, nil) - req.Header.Set("Authorization", tc.bearer) - res := httptest.NewRecorder() - ConnectionHandler(s, gateway.NewRegistry()).ServeHTTP(res, req) - if res.Code != tc.code || s.calls != tc.calls || res.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("%s %s: %d, %d calls", tc.method, tc.query, res.Code, s.calls) - } - if strings.Contains(res.Body.String(), "private") || strings.Contains(res.Body.String(), "test-key") { - t.Fatal("private data exposed") - } - if res.Code == 200 && res.Body.String() != `{"environment_id":"environment","status":"disconnected"}`+"\n" { - t.Fatal("unexpected response", res.Body.String()) - } - } -} - -// A real gateway peer captures its digest at HTTP upgrade. The test store makes -// authority changes at the deterministic post-peer recheck, without timing sleeps. -type liveConnectionStore struct { - digest string - authCalls int - credentialCalls int - revokeAtRecheck bool - deviceRevokedAtRecheck bool - recheckError error - credentialRecheckError error -} - -func (s *liveConnectionStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { - s.authCalls++ - if s.authCalls == 2 { - if s.recheckError != nil { - return "", s.recheckError - } - if s.revokeAtRecheck { - return "", store.ErrNotFound - } - } - return "tenant", nil -} -func (s *liveConnectionStore) GetEnvironment(context.Context, string, string) (store.Environment, error) { - return store.Environment{ID: "environment", SessionID: "session", Status: "connected"}, nil -} -func (s *liveConnectionStore) GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) { - return store.ExecutionDevice{ID: "device", EnvironmentID: "environment"}, nil -} -func (s *liveConnectionStore) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { - s.credentialCalls++ - if s.authCalls >= 2 && s.credentialRecheckError != nil { - return device.Credential{}, false, s.credentialRecheckError - } - if s.deviceRevokedAtRecheck && s.authCalls >= 2 { - return device.Credential{}, false, nil - } - return device.Credential{ID: "device", WorkspaceID: "tenant", Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: s.digest}, true, nil -} -func TestRuntimeConnectedCurrentAuthorityAfterPeer(t *testing.T) { - for _, name := range []string{"connected", "rotated before read", "revoked after peer", "device revoked after peer", "retired after peer", "store error after peer", "device store error after peer", "closed", "no registry"} { - t.Run(name, func(t *testing.T) { - digest := device.HashCredential("fixture-key") - s := &liveConnectionStore{digest: digest} - registry := gateway.NewRegistry() - handler := gateway.NewHandler(gateway.HandlerConfig{Registry: registry, Authenticator: gateway.NewAuthenticator(s)}) - server := httptest.NewServer(http.HandlerFunc(handler.WS)) - defer server.Close() - conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"?device_id=device&version="+proto.Version, http.Header{"Authorization": {"Bearer fixture-key"}}) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - // Wait for the actual registration, not merely the transport upgrade. - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - peer, err := registry.WaitForDevice(ctx, "device", time.Second) - if err != nil { - t.Fatal(err) - } - defer peer.Close("test complete") - s.authCalls = 0 - var wantErr error - want := name == "connected" - switch name { - case "rotated before read": - s.digest = device.HashCredential("new-key") - digest = s.digest - case "revoked after peer": - s.revokeAtRecheck = true - wantErr = store.ErrNotFound - case "device revoked after peer", "retired after peer": - s.deviceRevokedAtRecheck = true - wantErr = store.ErrNotFound - case "store error after peer": - s.recheckError = errors.New("database unavailable") - wantErr = s.recheckError - case "device store error after peer": - s.credentialRecheckError = errors.New("device authority unavailable") - wantErr = s.credentialRecheckError - case "closed": - peer.Close("closed before observation") - case "no registry": - registry = nil - } - connected, err := RuntimeConnected(t.Context(), s, registry, "environment", digest) - if connected != want || !errors.Is(err, wantErr) { - t.Fatalf("connected=%v err=%v", connected, err) - } - if name == "connected" && s.authCalls != 2 { - t.Fatal("post-peer authority was not checked") - } - }) - } -} diff --git a/services/agents-api/internal/runtimehistory/postgresreader/exporter.go b/services/agents-api/internal/runtimehistory/postgresreader/exporter.go deleted file mode 100644 index f61719dff..000000000 --- a/services/agents-api/internal/runtimehistory/postgresreader/exporter.go +++ /dev/null @@ -1,101 +0,0 @@ -package postgresreader - -import ( - "context" - "errors" - "math" - "regexp" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/google/uuid" -) - -var providerPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) - -func (r *Reader) Export(ctx context.Context, record runtimeobs.ExportRecord) error { - if record.CollectionSource != runtimeobs.CollectionSourcePeriodic { - return nil - } - if err := validateRecord(record); err != nil { - return err - } - if record.ResolvedAt.Before(r.now().Add(-retention)) { - return nil - } - // Counters without a provider incarnation cannot safely participate in rates. - if record.Sample != nil && record.Sample.StartedAt == nil { - value := *record.Sample - value.CPUUsageSecondsTotal = nil - value.CPUCapacityCores = nil - value.CPUUtilizationRatio = nil - value.MemoryUsageBytes = nil - value.MemoryLimitBytes = nil - record.Sample = &value - } - ctx, cancel := context.WithTimeout(ctx, r.queryTimeout) - defer cancel() - if err := r.store.InsertRuntimeHistorySample(ctx, record); err != nil { - return errors.New("persist Runtime history") - } - return nil -} - -func validateRecord(record runtimeobs.ExportRecord) error { - invalid := errors.New("invalid Runtime history sample") - for _, value := range []string{record.TenantID, record.SessionID, record.EnvironmentID} { - if !validID(value) { - return invalid - } - } - if record.AllocationID != "" && !validID(record.AllocationID) { - return invalid - } - if record.Mode != runtimeobs.ModeManaged || record.CollectionSource != runtimeobs.CollectionSourcePeriodic || !validTime(record.ResolvedAt) || record.ProviderType != "" && !providerPattern.MatchString(record.ProviderType) { - return invalid - } - if record.Status != runtimeobs.StatusObserved && record.Status != runtimeobs.StatusUnavailable && record.Status != runtimeobs.StatusUnsupported { - return invalid - } - if (record.Status == runtimeobs.StatusObserved) != (record.Sample != nil) { - return invalid - } - if value := record.Sample; value != nil { - if !validTime(value.ObservedAt) || value.ObservedAt.After(record.ResolvedAt) { - return invalid - } - if value.StartedAt != nil && (!validTime(*value.StartedAt) || value.StartedAt.After(value.ObservedAt) || record.AllocationID == "" || record.ProviderType == "") { - return invalid - } - if value.CPUUsageSecondsTotal != nil && (!validFloat(*value.CPUUsageSecondsTotal) || *value.CPUUsageSecondsTotal < 0) { - return invalid - } - if value.CPUCapacityCores != nil && (!validFloat(*value.CPUCapacityCores) || *value.CPUCapacityCores <= 0) { - return invalid - } - if value.CPUUtilizationRatio != nil && (!validFloat(*value.CPUUtilizationRatio) || *value.CPUUtilizationRatio < 0) { - return invalid - } - if value.MemoryUsageBytes != nil && *value.MemoryUsageBytes > maxSafeInteger { - return invalid - } - if value.MemoryLimitBytes != nil && (*value.MemoryLimitBytes == 0 || *value.MemoryLimitBytes > maxSafeInteger) { - return invalid - } - } - if value := record.TokenUsage; value != nil && (value.InputTokens > maxSafeInteger || value.OutputTokens > maxSafeInteger) { - return invalid - } - return nil -} - -const maxSafeInteger = uint64(1<<53 - 1) - -func validID(value string) bool { - parsed, err := uuid.Parse(value) - return err == nil && parsed != uuid.Nil && parsed.String() == value -} -func validTime(value time.Time) bool { - return !value.IsZero() && value.UnixNano() >= 0 && value.Equal(time.Unix(0, value.UnixNano())) -} -func validFloat(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } diff --git a/services/agents-api/internal/runtimehistory/storeresolver/resolver.go b/services/agents-api/internal/runtimehistory/storeresolver/resolver.go deleted file mode 100644 index 171743ec0..000000000 --- a/services/agents-api/internal/runtimehistory/storeresolver/resolver.go +++ /dev/null @@ -1,48 +0,0 @@ -package storeresolver - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type environmentStore interface { - GetSessionEnvironment(context.Context, string, string) (store.Environment, error) -} - -type Resolver struct{ store environmentStore } - -func NewResolver(value environmentStore) (*Resolver, error) { - if value == nil { - return nil, errors.New("Runtime history store is required") - } - return &Resolver{store: value}, nil -} - -// ResolveRuntimeHistoryScope authorizes the Session through the Core store and -// returns only durable Core identity. It intentionally does not resolve a -// current allocation: retained history may contain earlier allocations. The -// Reader keeps each durable allocation as one continuous series. -func (r *Resolver) ResolveRuntimeHistoryScope(ctx context.Context, tenantID, sessionID string) (runtimehistory.Scope, error) { - environment, err := r.store.GetSessionEnvironment(ctx, tenantID, sessionID) - if err != nil { - return runtimehistory.Scope{}, fmt.Errorf("resolve Runtime history Environment: %w", err) - } - if environment.TenantID != tenantID || environment.SessionID != sessionID { - return runtimehistory.Scope{}, errors.New("Runtime history Environment does not match resolved ownership") - } - var configuration struct { - Type string `json:"type"` - } - if json.Unmarshal(environment.Configuration, &configuration) != nil || configuration.Type == "" { - return runtimehistory.Scope{}, errors.New("invalid stored Runtime history environment configuration") - } - if configuration.Type != "openai_hosted" { - return runtimehistory.Scope{}, runtimehistory.ErrUnsupported - } - return runtimehistory.Scope{TenantID: tenantID, SessionID: sessionID, EnvironmentID: environment.ID}, nil -} diff --git a/services/agents-api/internal/runtimehistory/storeresolver/resolver_test.go b/services/agents-api/internal/runtimehistory/storeresolver/resolver_test.go deleted file mode 100644 index 0b4533348..000000000 --- a/services/agents-api/internal/runtimehistory/storeresolver/resolver_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package storeresolver - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type resolverStore struct { - environment store.Environment - err error - calls int -} - -func (s *resolverStore) GetSessionEnvironment(context.Context, string, string) (store.Environment, error) { - s.calls++ - return s.environment, s.err -} - -func TestResolverAuthorizesManagedSessionWithoutSelectingCurrentAllocation(t *testing.T) { - backend := &resolverStore{environment: store.Environment{ - ID: environmentID, TenantID: tenantID, SessionID: sessionID, - Configuration: []byte(`{"type":"openai_hosted"}`), - }} - resolver, err := NewResolver(backend) - if err != nil { - t.Fatal(err) - } - scope, err := resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID) - if err != nil { - t.Fatal(err) - } - if scope != (runtimehistory.Scope{TenantID: tenantID, SessionID: sessionID, EnvironmentID: environmentID}) || backend.calls != 1 { - t.Fatalf("unexpected Runtime history scope: %+v calls=%d", scope, backend.calls) - } -} - -func TestResolverPreservesTenantScopedNotFound(t *testing.T) { - backend := &resolverStore{err: store.ErrNotFound} - resolver, err := NewResolver(backend) - if err != nil { - t.Fatal(err) - } - _, err = resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID) - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("tenant-scoped not found was not preserved: %v", err) - } -} - -func TestResolverRejectsUnsupportedOrMismatchedEnvironment(t *testing.T) { - for _, environment := range []store.Environment{ - {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, - {ID: environmentID, TenantID: "55555555-5555-4555-8555-555555555555", SessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, - {ID: environmentID, TenantID: tenantID, SessionID: "66666666-6666-4666-8666-666666666666", Configuration: []byte(`{"type":"openai_hosted"}`)}, - {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":`)}, - } { - resolver, err := NewResolver(&resolverStore{environment: environment}) - if err != nil { - t.Fatal(err) - } - if _, err := resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID); err == nil { - t.Fatalf("unsafe Runtime history Environment accepted: %+v", environment) - } - } -} - -const ( - tenantID = "11111111-1111-4111-8111-111111111111" - sessionID = "22222222-2222-4222-8222-222222222222" - environmentID = "33333333-3333-4333-8333-333333333333" -) diff --git a/services/agents-api/internal/runtimeobs/operations_test.go b/services/agents-api/internal/runtimeobs/operations_test.go deleted file mode 100644 index 50f30ea98..000000000 --- a/services/agents-api/internal/runtimeobs/operations_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package runtimeobs - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "testing" - "time" -) - -type failingBatchSource struct { - *fixedSource - batchErr error - batches int -} - -func (*failingBatchSource) ProviderOperations() providercontract.Operations { - return providercontract.Operations{"Observe": {State: providercontract.Supported}, "ObserveBatch": {State: providercontract.Supported}} -} -func (s *failingBatchSource) ObserveBatch(context.Context, []Target) ([]BatchResult, error) { - s.batches++ - return nil, s.batchErr -} -func TestBatchFallbackRequiresExplicitSafeUnsupported(t *testing.T) { - for _, test := range []struct { - name string - err error - fallback bool - }{ - {"unsupported", &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "native_batch_not_supported"}, true}, - {"unavailable", ErrUnavailable, false}, - {"timeout", context.DeadlineExceeded, false}, - {"failure", errors.New("provider failed"), false}, - {"bare unsupported", providercontract.ErrUnsupported, false}, - {"unsafe unsupported", &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "private endpoint / key"}, false}, - {"wrong operation", &providercontract.UnsupportedError{Operation: "Observe", Reason: "not_supported"}, false}, - } { - t.Run(test.name, func(t *testing.T) { - now := time.Now() - ratio := 0.5 - source := &failingBatchSource{fixedSource: &fixedSource{sample: Sample{ObservedAt: now, CPUUtilizationRatio: &ratio}}, batchErr: test.err} - target := Target{EnvironmentID: "environment", Mode: ModeManaged, Instance: Instance{AllocationID: "allocation", ProviderKey: "provider", AllocationState: "running"}} - service, err := NewService(fixedResolver{target: target}, map[string]Source{"provider": source}) - if err != nil { - t.Fatal(err) - } - observations, errs := service.ObserveSessions(t.Context(), []SessionIdentity{{TenantID: "tenant", SessionID: "session"}}, PageOptions{}) - if source.batches != 1 || (source.calls == 1) != test.fallback { - t.Fatalf("batch=%d single=%d", source.batches, source.calls) - } - if test.fallback && (errs[0] != nil || observations[0].Status != StatusObserved) { - t.Fatal(observations, errs) - } - }) - } -} - -type unsupportedObservation struct{ *fixedSource } - -func (*unsupportedObservation) ProviderOperations() providercontract.Operations { - return providercontract.Operations{"Observe": {State: providercontract.Unsupported, Reason: "native_metrics_not_supported"}, "ObserveBatch": {State: providercontract.Unsupported, Reason: "native_metrics_not_supported"}} -} -func TestUnsupportedObservationIsNotUnavailable(t *testing.T) { - source := &unsupportedObservation{&fixedSource{}} - target := Target{EnvironmentID: "environment", Mode: ModeManaged, Instance: Instance{AllocationID: "allocation", ProviderKey: "provider", AllocationState: "running"}} - service, err := NewService(fixedResolver{target: target}, map[string]Source{"provider": source}) - if err != nil { - t.Fatal(err) - } - observation, err := service.ObserveSession(t.Context(), "tenant", "session") - if err != nil || observation.Status != StatusUnsupported || observation.Reason != "native_metrics_not_supported" || source.calls != 0 { - t.Fatal(observation, err, source.calls) - } -} diff --git a/services/agents-api/internal/runtimeobs/otlpexporter/exporter.go b/services/agents-api/internal/runtimeobs/otlpexporter/exporter.go deleted file mode 100644 index cfd332406..000000000 --- a/services/agents-api/internal/runtimeobs/otlpexporter/exporter.go +++ /dev/null @@ -1,309 +0,0 @@ -// Package otlpexporter sends sanitized Runtime observation records to an -// operator-configured OTLP/HTTP metrics endpoint. It is optional history -// transport, not Runtime execution or lifecycle authority. -package otlpexporter - -import ( - "context" - "errors" - "math" - "regexp" - "time" - - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" - "go.opentelemetry.io/otel/sdk/instrumentation" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - "go.opentelemetry.io/otel/sdk/resource" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" -) - -const scopeName = "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs/otlpexporter" - -const ( - CPUUsageName = "agents.runtime.cpu.usage" - CPUCapacityName = "agents.runtime.cpu.capacity" - CPUUtilizationName = "agents.runtime.cpu.utilization" - MemoryUsageName = "agents.runtime.memory.usage" - MemoryLimitName = "agents.runtime.memory.limit" - SampleName = "agents.runtime.sample" - SampleDurationName = "agents.runtime.sample.duration" - TokenInputName = "agents.session.tokens.input" - TokenOutputName = "agents.session.tokens.output" -) - -var safeLabelPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) - -type Config struct { - Endpoint string - Headers map[string]string - Insecure bool - RequestTimeout time.Duration -} - -type metricClient interface { - Export(context.Context, *metricdata.ResourceMetrics) error - Shutdown(context.Context) error -} - -type Exporter struct { - client metricClient - resource *resource.Resource -} - -func New(ctx context.Context, config Config) (*Exporter, error) { - if config.Endpoint == "" { - return nil, errors.New("Runtime history OTLP endpoint is required") - } - options := []otlpmetrichttp.Option{ - otlpmetrichttp.WithEndpointURL(config.Endpoint), - otlpmetrichttp.WithHeaders(config.Headers), - otlpmetrichttp.WithRetry(otlpmetrichttp.RetryConfig{Enabled: true}), - } - if config.Insecure { - options = append(options, otlpmetrichttp.WithInsecure()) - } - if config.RequestTimeout > 0 { - options = append(options, otlpmetrichttp.WithTimeout(config.RequestTimeout)) - } - client, err := otlpmetrichttp.New(ctx, options...) - if err != nil { - return nil, errors.New("cannot initialize Runtime history OTLP exporter") - } - return newWithClient(client), nil -} - -func newWithClient(client metricClient) *Exporter { - return &Exporter{ - client: client, - resource: resource.NewSchemaless( - attribute.String("service.name", "oac-core"), - attribute.String("service.namespace", "oac"), - ), - } -} - -func (e *Exporter) Export(ctx context.Context, record runtimeobs.ExportRecord) error { - metrics, err := recordMetrics(record) - if err != nil { - return err - } - return e.client.Export(ctx, &metricdata.ResourceMetrics{ - Resource: e.resource, - ScopeMetrics: []metricdata.ScopeMetrics{{ - Scope: instrumentation.Scope{Name: scopeName}, - Metrics: metrics, - }}, - }) -} - -func (e *Exporter) Close(ctx context.Context) error { - return e.client.Shutdown(ctx) -} - -func recordMetrics(record runtimeobs.ExportRecord) ([]metricdata.Metrics, error) { - if err := validateRecord(record); err != nil { - return nil, err - } - attributes := recordAttributes(record) - result := []metricdata.Metrics{deltaCountMetric(SampleName, "Validated Runtime observation results", record.ResolvedAt, attributes)} - if record.SourceDuration > 0 { - result = append(result, durationMetric(record, attributes)) - } - if record.TokenUsage != nil { - result = append(result, - integerGaugeMetric(TokenInputName, "Cumulative measured Session input tokens", "{token}", int64(record.TokenUsage.InputTokens), record.ResolvedAt, attributes), - integerGaugeMetric(TokenOutputName, "Cumulative measured Session output tokens", "{token}", int64(record.TokenUsage.OutputTokens), record.ResolvedAt, attributes), - ) - } - if record.Sample == nil { - return result, nil - } - - sample := record.Sample - // Resource values are meaningful only within a provider-qualified compute - // incarnation. Keep the coverage result, but never let an unfenced sample - // collapse CPU or memory points across Runtime restarts. - if sample.StartedAt == nil { - return result, nil - } - if sample.CPUUsageSecondsTotal != nil { - result = append(result, cumulativeMetric( - CPUUsageName, "Cumulative CPU time consumed by the Runtime incarnation", "s", - *sample.CPUUsageSecondsTotal, sample.StartedAt, sample.ObservedAt, attributes, - )) - } - if sample.CPUCapacityCores != nil { - result = append(result, gaugeMetric(CPUCapacityName, "Configured Runtime CPU capacity", "{core}", *sample.CPUCapacityCores, sample.ObservedAt, attributes)) - } - if sample.CPUUtilizationRatio != nil { - result = append(result, gaugeMetric(CPUUtilizationName, "Provider-reported share of Runtime CPU capacity", "1", *sample.CPUUtilizationRatio, sample.ObservedAt, attributes)) - } - if sample.MemoryUsageBytes != nil { - result = append(result, gaugeMetric(MemoryUsageName, "Current Runtime memory usage", "By", float64(*sample.MemoryUsageBytes), sample.ObservedAt, attributes)) - } - if sample.MemoryLimitBytes != nil { - result = append(result, gaugeMetric(MemoryLimitName, "Configured Runtime memory limit", "By", float64(*sample.MemoryLimitBytes), sample.ObservedAt, attributes)) - } - return result, nil -} - -func validateRecord(record runtimeobs.ExportRecord) error { - if record.TenantID == "" || record.SessionID == "" || record.ResolvedAt.IsZero() || record.ResolvedAt.Unix() < 0 { - return errors.New("invalid Runtime history export identity") - } - if record.ProviderType != "" && !safeLabelPattern.MatchString(record.ProviderType) { - return errors.New("invalid Runtime history provider type") - } - if record.Reason != "" && !safeLabelPattern.MatchString(record.Reason) { - return errors.New("invalid Runtime history result reason") - } - switch record.Mode { - case runtimeobs.ModeNone, runtimeobs.ModeSelfHosted, runtimeobs.ModeManaged: - default: - return errors.New("invalid Runtime history mode") - } - switch record.Status { - case runtimeobs.StatusObserved: - if record.Sample == nil { - return errors.New("observed Runtime history record has no sample") - } - case runtimeobs.StatusUnavailable, runtimeobs.StatusUnsupported: - if record.Sample != nil { - return errors.New("unobserved Runtime history record has a sample") - } - default: - return errors.New("invalid Runtime history status") - } - switch record.CollectionSource { - case runtimeobs.CollectionSourceOnRead, runtimeobs.CollectionSourcePeriodic: - default: - return errors.New("invalid Runtime history collection source") - } - if record.SourceDuration < 0 { - return errors.New("invalid Runtime history source duration") - } - if record.SourceDuration > 0 && record.ResolvedAt.Add(-record.SourceDuration).Unix() < 0 { - return errors.New("invalid Runtime history source start time") - } - const maxSafeInteger = uint64(1<<53 - 1) - if record.TokenUsage != nil && (record.TokenUsage.InputTokens > maxSafeInteger || record.TokenUsage.OutputTokens > maxSafeInteger) { - return errors.New("invalid Runtime history token usage") - } - if record.Sample == nil { - return nil - } - return validateSample(record.Sample, record.ResolvedAt) -} - -func validateSample(sample *runtimeobs.Sample, resolvedAt time.Time) error { - if sample.ObservedAt.IsZero() || sample.ObservedAt.Unix() < 0 || sample.ObservedAt.After(resolvedAt) { - return errors.New("invalid Runtime history sample time") - } - if sample.StartedAt != nil && (sample.StartedAt.IsZero() || sample.StartedAt.Unix() < 0 || sample.StartedAt.After(sample.ObservedAt)) { - return errors.New("invalid Runtime history start time") - } - if sample.CPUUsageSecondsTotal != nil && (*sample.CPUUsageSecondsTotal < 0 || math.IsNaN(*sample.CPUUsageSecondsTotal) || math.IsInf(*sample.CPUUsageSecondsTotal, 0)) { - return errors.New("invalid Runtime history CPU usage") - } - if sample.CPUCapacityCores != nil && (*sample.CPUCapacityCores <= 0 || math.IsNaN(*sample.CPUCapacityCores) || math.IsInf(*sample.CPUCapacityCores, 0)) { - return errors.New("invalid Runtime history CPU capacity") - } - if sample.CPUUtilizationRatio != nil && (*sample.CPUUtilizationRatio < 0 || math.IsNaN(*sample.CPUUtilizationRatio) || math.IsInf(*sample.CPUUtilizationRatio, 0)) { - return errors.New("invalid Runtime history CPU utilization") - } - const maxSafeInteger = uint64(1<<53 - 1) - if sample.MemoryUsageBytes != nil && *sample.MemoryUsageBytes > maxSafeInteger { - return errors.New("invalid Runtime history memory usage") - } - if sample.MemoryLimitBytes != nil && (*sample.MemoryLimitBytes == 0 || *sample.MemoryLimitBytes > maxSafeInteger) { - return errors.New("invalid Runtime history memory limit") - } - return nil -} - -func recordAttributes(record runtimeobs.ExportRecord) attribute.Set { - values := []attribute.KeyValue{ - attribute.String("agents.tenant.id", record.TenantID), - attribute.String("agents.session.id", record.SessionID), - attribute.String("agents.runtime.mode", string(record.Mode)), - attribute.String("agents.runtime.status", string(record.Status)), - attribute.String("agents.runtime.collection.source", string(record.CollectionSource)), - attribute.Int64("agents.runtime.resolved_at_unix_nano", record.ResolvedAt.UnixNano()), - } - if record.EnvironmentID != "" { - values = append(values, attribute.String("agents.environment.id", record.EnvironmentID)) - } - if record.AllocationID != "" { - values = append(values, attribute.String("agents.runtime.allocation.id", record.AllocationID)) - } - if record.ProviderType != "" { - values = append(values, attribute.String("agents.runtime.provider.type", record.ProviderType)) - } - if record.Reason != "" { - values = append(values, attribute.String("agents.runtime.reason", record.Reason)) - } - if record.Sample != nil && record.Sample.StartedAt != nil { - values = append(values, attribute.Int64("agents.runtime.compute.started_at_unix_nano", record.Sample.StartedAt.UnixNano())) - } - if record.Sample != nil { - values = append(values, attribute.Int64("agents.runtime.observed_at_unix_nano", record.Sample.ObservedAt.UnixNano())) - } - return attribute.NewSet(values...) -} - -func deltaCountMetric(name, description string, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { - return metricdata.Metrics{Name: name, Description: description, Unit: "{result}", Data: metricdata.Sum[int64]{ - DataPoints: []metricdata.DataPoint[int64]{{Attributes: attributes, StartTime: observedAt, Time: observedAt, Value: 1}}, - Temporality: metricdata.DeltaTemporality, IsMonotonic: true, - }} -} - -func durationMetric(record runtimeobs.ExportRecord, attributes attribute.Set) metricdata.Metrics { - duration := record.SourceDuration.Seconds() - bounds := []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10} - buckets := make([]uint64, len(bounds)+1) - index := len(bounds) - for i, bound := range bounds { - if duration <= bound { - index = i - break - } - } - buckets[index] = 1 - start := record.ResolvedAt.Add(-record.SourceDuration) - return metricdata.Metrics{Name: SampleDurationName, Description: "Provider Runtime sample duration", Unit: "s", Data: metricdata.Histogram[float64]{ - DataPoints: []metricdata.HistogramDataPoint[float64]{{ - Attributes: attributes, StartTime: start, Time: record.ResolvedAt, Count: 1, - Bounds: bounds, BucketCounts: buckets, Min: metricdata.NewExtrema(duration), Max: metricdata.NewExtrema(duration), Sum: duration, - }}, - Temporality: metricdata.DeltaTemporality, - }} -} - -func cumulativeMetric(name, description, unit string, value float64, startedAt *time.Time, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { - point := metricdata.DataPoint[float64]{Attributes: attributes, Time: observedAt, Value: value} - if startedAt != nil { - point.StartTime = *startedAt - } - return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Sum[float64]{ - DataPoints: []metricdata.DataPoint[float64]{point}, Temporality: metricdata.CumulativeTemporality, IsMonotonic: true, - }} -} - -func gaugeMetric(name, description, unit string, value float64, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { - return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Gauge[float64]{ - DataPoints: []metricdata.DataPoint[float64]{{Attributes: attributes, Time: observedAt, Value: value}}, - }} -} - -func integerGaugeMetric(name, description, unit string, value int64, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { - return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Gauge[int64]{ - DataPoints: []metricdata.DataPoint[int64]{{Attributes: attributes, Time: observedAt, Value: value}}, - }} -} - -var _ sdkmetric.Exporter = (*otlpmetrichttp.Exporter)(nil) -var _ runtimeobs.Exporter = (*Exporter)(nil) diff --git a/services/agents-api/internal/runtimeobs/storeresolver/resolver.go b/services/agents-api/internal/runtimeobs/storeresolver/resolver.go deleted file mode 100644 index dd6433bf0..000000000 --- a/services/agents-api/internal/runtimeobs/storeresolver/resolver.go +++ /dev/null @@ -1,158 +0,0 @@ -package storeresolver - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type sessionStore interface { - GetSession(context.Context, string, string) (store.Session, error) - MeasuredSessionUsage(context.Context, string, string) (json.RawMessage, error) - GetRuntimeAllocation(context.Context, string, string) (store.RuntimeAllocation, error) - ListRuntimeObservationSessions(context.Context, string, int) (store.RuntimeObservationSessionPage, error) -} - -type Resolver struct{ store sessionStore } - -func NewResolver(s sessionStore) (*Resolver, error) { - if s == nil { - return nil, errors.New("Runtime observation store is required") - } - return &Resolver{store: s}, nil -} - -func (r *Resolver) Resolve(ctx context.Context, tenantID, sessionID string) (runtimeobs.Target, error) { - session, err := r.store.GetSession(ctx, tenantID, sessionID) - if err != nil { - return runtimeobs.Target{}, fmt.Errorf("resolve Runtime Session: %w", err) - } - var configuration struct { - Environment *struct { - Type string `json:"type"` - } `json:"environment"` - } - if err := json.Unmarshal(session.Configuration, &configuration); err != nil || configuration.Environment == nil { - return runtimeobs.Target{}, errors.New("invalid stored Runtime environment configuration") - } - target := runtimeobs.Target{TenantID: session.TenantID, SessionID: session.ID, Mode: runtimeobs.Mode(configuration.Environment.Type)} - // Telemetry counts measured usage continuously, including active Turns. - // Public Session usage stays null until every root Turn ends measured. - measured, err := r.store.MeasuredSessionUsage(ctx, session.TenantID, session.ID) - if err != nil { - return runtimeobs.Target{}, fmt.Errorf("resolve Runtime Session usage: %w", err) - } - usage, err := decodeTokenUsage(measured) - if err != nil { - return runtimeobs.Target{}, err - } - target.TokenUsage = usage - switch target.Mode { - case runtimeobs.ModeNone: - if session.Environment != nil { - return runtimeobs.Target{}, errors.New("environment:none unexpectedly has a durable Environment") - } - return target, nil - case runtimeobs.ModeSelfHosted: - if session.Environment == nil { - return runtimeobs.Target{}, errors.New("self-hosted Session is missing its Environment") - } - if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { - return runtimeobs.Target{}, errors.New("self-hosted Environment does not match resolved ownership") - } - target.EnvironmentID = session.Environment.ID - return target, nil - case runtimeobs.ModeManaged: - if session.Environment == nil { - return runtimeobs.Target{}, errors.New("managed Session is missing its Environment") - } - if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { - return runtimeobs.Target{}, errors.New("managed Environment does not match resolved ownership") - } - target.EnvironmentID = session.Environment.ID - allocation, err := r.store.GetRuntimeAllocation(ctx, tenantID, target.EnvironmentID) - if errors.Is(err, store.ErrNotFound) { - return target, runtimeobs.ErrUnavailable - } - if err != nil { - return runtimeobs.Target{}, fmt.Errorf("resolve Runtime allocation: %w", err) - } - if allocation.TenantID != tenantID || allocation.SessionID != session.ID || allocation.EnvironmentID != target.EnvironmentID { - return runtimeobs.Target{}, errors.New("Runtime allocation does not match resolved ownership") - } - target.Instance = runtimeobs.Instance{ - AllocationID: allocation.ID, ProviderKey: allocation.ProviderKey, DeviceID: allocation.DeviceID, - AllocationState: allocation.State, AllocationCreatedAt: allocation.CreatedAt, - ComputePhase: allocation.ComputePhase, ProviderState: append(json.RawMessage(nil), allocation.ComputeState...), - } - return target, nil - default: - return runtimeobs.Target{}, errors.New("invalid stored Runtime environment type") - } -} - -type storedTokenUsage struct { - InputTokens *int64 `json:"input_tokens"` - InputTokensDetails *storedInputTokenDetails `json:"input_tokens_details"` - OutputTokens *int64 `json:"output_tokens"` - OutputTokensDetails *storedOutputTokenDetails `json:"output_tokens_details"` - TotalTokens *int64 `json:"total_tokens"` -} - -type storedInputTokenDetails struct { - CachedTokens *int64 `json:"cached_tokens"` -} - -type storedOutputTokenDetails struct { - ReasoningTokens *int64 `json:"reasoning_tokens"` -} - -func decodeTokenUsage(raw json.RawMessage) (*runtimeobs.TokenUsage, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - return nil, nil - } - var usage storedTokenUsage - decoder := json.NewDecoder(bytes.NewReader(trimmed)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&usage); err != nil { - return nil, errors.New("invalid stored Session token usage") - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return nil, errors.New("invalid stored Session token usage") - } - if usage.InputTokens == nil || usage.OutputTokens == nil || usage.TotalTokens == nil || - usage.InputTokensDetails == nil || usage.InputTokensDetails.CachedTokens == nil || - usage.OutputTokensDetails == nil || usage.OutputTokensDetails.ReasoningTokens == nil { - return nil, errors.New("invalid stored Session token usage") - } - const maxSafeInteger = int64(1<<53 - 1) - values := []int64{*usage.InputTokens, *usage.OutputTokens, *usage.TotalTokens, *usage.InputTokensDetails.CachedTokens, *usage.OutputTokensDetails.ReasoningTokens} - for _, value := range values { - if value < 0 || value > maxSafeInteger { - return nil, errors.New("invalid stored Session token usage") - } - } - if *usage.InputTokens+*usage.OutputTokens != *usage.TotalTokens { - return nil, errors.New("invalid stored Session token usage") - } - return &runtimeobs.TokenUsage{InputTokens: uint64(*usage.InputTokens), OutputTokens: uint64(*usage.OutputTokens)}, nil -} - -func (r *Resolver) ListRuntimeObservationSessions(ctx context.Context, after string, limit int) (runtimeobs.SessionPage, error) { - page, err := r.store.ListRuntimeObservationSessions(ctx, after, limit) - if err != nil { - return runtimeobs.SessionPage{}, err - } - result := runtimeobs.SessionPage{Sessions: make([]runtimeobs.SessionIdentity, 0, len(page.Sessions)), NextCursor: page.NextCursor} - for _, session := range page.Sessions { - result.Sessions = append(result.Sessions, runtimeobs.SessionIdentity{TenantID: session.TenantID, SessionID: session.SessionID}) - } - return result, nil -} diff --git a/services/agents-api/internal/runtimeobs/storeresolver/resolver_test.go b/services/agents-api/internal/runtimeobs/storeresolver/resolver_test.go deleted file mode 100644 index c04a93a76..000000000 --- a/services/agents-api/internal/runtimeobs/storeresolver/resolver_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package storeresolver - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type resolverStore struct { - session store.Session - measured json.RawMessage - allocation store.RuntimeAllocation - allocationErr error - page store.RuntimeObservationSessionPage -} - -func (s resolverStore) GetSession(context.Context, string, string) (store.Session, error) { - return s.session, nil -} - -func (s resolverStore) MeasuredSessionUsage(_ context.Context, tenant, session string) (json.RawMessage, error) { - if tenant != s.session.TenantID || session != s.session.ID { - return nil, errors.New("measured usage read for another Session") - } - return s.measured, nil -} - -func (s resolverStore) GetRuntimeAllocation(context.Context, string, string) (store.RuntimeAllocation, error) { - return s.allocation, s.allocationErr -} - -func (s resolverStore) ListRuntimeObservationSessions(context.Context, string, int) (store.RuntimeObservationSessionPage, error) { - return s.page, nil -} - -func TestResolverListsOnlyProviderNeutralSessionIdentity(t *testing.T) { - r, err := NewResolver(resolverStore{page: store.RuntimeObservationSessionPage{ - Sessions: []store.RuntimeObservationSession{{TenantID: "tenant", SessionID: "session"}}, - NextCursor: "session", - }}) - if err != nil { - t.Fatal(err) - } - page, err := r.ListRuntimeObservationSessions(t.Context(), "", 32) - if err != nil || len(page.Sessions) != 1 || page.Sessions[0].TenantID != "tenant" || page.Sessions[0].SessionID != "session" || page.NextCursor != "session" { - t.Fatalf("unexpected observation scan identity: %+v %v", page, err) - } -} - -func TestResolverBindsManagedSessionEnvironmentAndAllocation(t *testing.T) { - r, err := NewResolver(resolverStore{ - session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, - measured: []byte(`{"input_tokens":120,"input_tokens_details":{"cached_tokens":20},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":150}`), - allocation: store.RuntimeAllocation{ - ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", - ProviderKey: "provider", DeviceID: "device", ComputePhase: "running", ComputeState: []byte(`{"current":{"name":"sandbox"}}`), - }, - }) - if err != nil { - t.Fatal(err) - } - target, err := r.Resolve(t.Context(), "tenant", "session") - if err != nil { - t.Fatal(err) - } - if target.TenantID != "tenant" || target.SessionID != "session" || target.EnvironmentID != "environment" || target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID != "allocation" || target.Instance.ProviderKey != "provider" || target.Instance.DeviceID != "device" { - t.Fatalf("incorrect managed identity binding: %+v", target) - } - if string(target.Instance.ProviderState) != `{"current":{"name":"sandbox"}}` { - t.Fatalf("provider state was not retained: %s", target.Instance.ProviderState) - } - if target.Instance.ComputePhase != "running" { - t.Fatalf("compute phase was not retained: %s", target.Instance.ComputePhase) - } - if target.TokenUsage == nil || target.TokenUsage.InputTokens != 120 || target.TokenUsage.OutputTokens != 30 { - t.Fatalf("measured Session usage was not retained: %+v", target.TokenUsage) - } -} - -func TestResolverRejectsInvalidCanonicalSessionUsage(t *testing.T) { - for _, usage := range []string{ - `{"input_tokens":2,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":4}`, - `{}`, - `{"total_tokens":0}`, - `{"input_tokens":0,"input_tokens_details":{"cached_tokens":-1},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`, - `{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0,"unknown":0}`, - } { - resolver, err := NewResolver(resolverStore{session: store.Session{ - ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), - }, measured: []byte(usage)}) - if err != nil { - t.Fatal(err) - } - if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { - t.Fatalf("invalid Session token usage was accepted: %s", usage) - } - } -} - -func TestResolverKeepsNullCanonicalSessionUsageAbsent(t *testing.T) { - resolver, err := NewResolver(resolverStore{session: store.Session{ - ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), - }, measured: []byte(" \n null \t")}) - if err != nil { - t.Fatal(err) - } - target, err := resolver.Resolve(t.Context(), "tenant", "session") - if err != nil || target.TokenUsage != nil { - t.Fatalf("null Session usage was not kept absent: %+v %v", target.TokenUsage, err) - } -} - -// Telemetry reads measured usage, not the public Session value, which stays -// null while a root Turn runs. -func TestResolverUsesMeasuredRatherThanPublicSessionUsage(t *testing.T) { - measured := `{"input_tokens":7,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":10}` - for _, test := range []struct { - public, measured string - want *runtimeobs.TokenUsage - }{ - {"null", measured, &runtimeobs.TokenUsage{InputTokens: 7, OutputTokens: 3}}, - {measured, "null", nil}, - } { - resolver, err := NewResolver(resolverStore{session: store.Session{ - ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), Usage: []byte(test.public), - }, measured: []byte(test.measured)}) - if err != nil { - t.Fatal(err) - } - target, err := resolver.Resolve(t.Context(), "tenant", "session") - if err != nil || (target.TokenUsage == nil) != (test.want == nil) || (test.want != nil && *target.TokenUsage != *test.want) { - t.Fatalf("usage source: %+v %v", target.TokenUsage, err) - } - } -} - -func TestResolverKeepsUnsupportedModesDistinct(t *testing.T) { - for _, tc := range []struct { - mode string - environment *store.Environment - }{ - {mode: "none"}, - {mode: "self_hosted", environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, - } { - r, err := NewResolver(resolverStore{session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"` + tc.mode + `"}}`), Environment: tc.environment}}) - if err != nil { - t.Fatal(err) - } - target, err := r.Resolve(t.Context(), "tenant", "session") - if err != nil || string(target.Mode) != tc.mode { - t.Fatalf("mode %s was not resolved accurately: %+v %v", tc.mode, target, err) - } - } -} - -func TestResolverReportsManagedAllocationAsUnavailable(t *testing.T) { - r, err := NewResolver(resolverStore{ - session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, - allocationErr: store.ErrNotFound, - }) - if err != nil { - t.Fatal(err) - } - target, err := r.Resolve(t.Context(), "tenant", "session") - if !errors.Is(err, runtimeobs.ErrUnavailable) || target.EnvironmentID != "environment" || target.Mode != runtimeobs.ModeManaged { - t.Fatalf("allocation absence was not preserved: %+v %v", target, err) - } -} - -func TestResolverRejectsMismatchedEnvironmentOwnership(t *testing.T) { - for _, mode := range []string{"self_hosted", "openai_hosted"} { - for _, environment := range []store.Environment{ - {ID: "environment", TenantID: "other", SessionID: "session"}, - {ID: "environment", TenantID: "tenant", SessionID: "other"}, - } { - resolver, err := NewResolver(resolverStore{session: store.Session{ - ID: "session", TenantID: "tenant", - Configuration: []byte(`{"environment":{"type":"` + mode + `"}}`), Environment: &environment, - }}) - if err != nil { - t.Fatal(err) - } - if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { - t.Fatalf("mismatched %s Environment accepted: %+v", mode, environment) - } - } - } -} - -func TestResolverRejectsMismatchedAllocationOwnership(t *testing.T) { - base := store.RuntimeAllocation{ - ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", - ProviderKey: "provider", DeviceID: "device", - } - for _, mutate := range []func(*store.RuntimeAllocation){ - func(value *store.RuntimeAllocation) { value.TenantID = "other" }, - func(value *store.RuntimeAllocation) { value.SessionID = "other" }, - func(value *store.RuntimeAllocation) { value.EnvironmentID = "other" }, - } { - allocation := base - mutate(&allocation) - resolver, err := NewResolver(resolverStore{ - session: store.Session{ - ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), - Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}, - }, - allocation: allocation, - }) - if err != nil { - t.Fatal(err) - } - if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { - t.Fatalf("mismatched allocation accepted: %+v", allocation) - } - } -} diff --git a/services/agents-api/internal/sandbox/contracttest/provider.go b/services/agents-api/internal/sandbox/contracttest/provider.go deleted file mode 100644 index 8c7555f53..000000000 --- a/services/agents-api/internal/sandbox/contracttest/provider.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package contracttest runs shared lifecycle assertions against real adapters -// backed by controlled native transports. It is test support only. -package contracttest - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "reflect" - "testing" - "time" -) - -type Fault string - -const ( - TransportFailure Fault = "transport_failure" - Canceled Fault = "canceled_after_dispatch" - ForeignOwnership Fault = "foreign_ownership" - CleanupFailure Fault = "cleanup_failure" -) - -type Scenario struct { - Operation string - Fault Fault -} - -// Fixture exposes the production adapter. Calls records all native requests; -// WantCalls includes legitimate multi-step work before the injected fault. Exact -// comparison detects replay, fallback and implicit cleanup after uncertain Create. -type Fixture struct { - Provider sandbox.SandboxProvider - Bootstrap sandbox.Bootstrap - Calls func() []string - WantCalls []string -} - -// Factory configures native responses. Invoke cancel only after dispatch begins. -type Factory func(t *testing.T, scenario Scenario, cancel context.CancelFunc) Fixture - -// RunFailures requires errors without mandating one native error type. Failed -// or canceled calls must not invent proof of settlement or bootstrap completion. -func RunFailures(t *testing.T, factory Factory) { - t.Helper() - for _, operation := range []string{"create", "inspect", "renew", "kill"} { - faults := []Fault{TransportFailure, Canceled, ForeignOwnership} - if operation == "kill" { - faults = append(faults, CleanupFailure) - } - for _, fault := range faults { - t.Run(operation+"/"+string(fault), func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - f := factory(t, Scenario{operation, fault}, cancel) - if err := sandbox.ValidateProvider(f.Provider); err != nil { - t.Fatal(err) - } - var info sandbox.Info - var err error - switch operation { - case "create": - info, err = f.Provider.Create(ctx, f.Bootstrap) - case "inspect": - info, err = f.Provider.GetInfo(ctx, f.Bootstrap.Reference) - case "renew": - info, err = f.Provider.Renew(ctx, f.Bootstrap.Reference) - case "kill": - err = f.Provider.Kill(ctx, f.Bootstrap.Reference) - } - if errors.Is(err, providercontract.ErrUnsupported) { - t.Fatal("required operation returned Unsupported", err) - } - if err == nil { - t.Fatal("native failure was reported as success") - } - if fault == Canceled && ctx.Err() == nil { - t.Fatal("fixture did not cancel the dispatched operation") - } - if info.CreateSettled || info.BootstrapComplete { - t.Fatalf("failed operation invented settlement or bootstrap proof: %+v", info) - } - if info.Reference != (sandbox.Reference{}) && info.Reference != f.Bootstrap.Reference { - t.Fatalf("failure exposed a foreign reference: %+v", info.Reference) - } - if got := f.Calls(); !reflect.DeepEqual(got, f.WantCalls) { - t.Fatalf("native calls = %v, want %v; unexpected replay, mutation or cleanup", got, f.WantCalls) - } - }) - } - } -} - -// AssertObservation checks identity and the expected native state, without -// equating a running process with authenticated Runtime or executor readiness. -func AssertObservation(t *testing.T, got sandbox.Info, err error, reference sandbox.Reference, providerID, state string) { - t.Helper() - if err != nil { - t.Fatal(err) - } - if got.Reference != reference || got.ProviderID == "" || providerID != "" && got.ProviderID != providerID || got.State != state { - t.Fatalf("observation lost identity or native state: %+v", got) - } -} diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go deleted file mode 100644 index 22b02ef04..000000000 --- a/services/agents-api/internal/sandbox/docker/bootstrap.go +++ /dev/null @@ -1,54 +0,0 @@ -package docker - -import ( - "archive/tar" - "bytes" - "context" - "io" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/moby/moby/client" -) - -type entry struct { - name string - content []byte - directory bool -} - -func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap) error { - // Deliver the Runtime-owned connection contract before native work can start. - auth, e := b.RuntimeConnection().Marshal() - if e != nil { - return e - } - if e = copyRuntimeFiles(ctx, p.client, id, "/home", []entry{ - {name: "runtime", directory: true}, {name: "runtime/.oac", directory: true}, - {name: "runtime/runtime-bootstrap.json", content: auth}, - }); e != nil { - return e - } - return copyRuntimeFiles(ctx, p.client, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) -} -func copyRuntimeFiles(ctx context.Context, c *client.Client, id, path string, entries []entry) error { - var content bytes.Buffer - writer := tar.NewWriter(&content) - for _, file := range entries { - header := &tar.Header{Name: file.name, Uid: 1000, Gid: 1000, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg} - if file.directory { - header.Mode = 0700 - header.Typeflag = tar.TypeDir - } - if e := writer.WriteHeader(header); e != nil { - return e - } - if _, e := writer.Write(file.content); e != nil { - return e - } - } - if e := writer.Close(); e != nil { - return e - } - _, e := c.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) - return e -} diff --git a/services/agents-api/internal/sandbox/docker/bootstrap_test.go b/services/agents-api/internal/sandbox/docker/bootstrap_test.go deleted file mode 100644 index 64edb000b..000000000 --- a/services/agents-api/internal/sandbox/docker/bootstrap_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package docker - -import ( - "archive/tar" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/moby/moby/client" -) - -func TestBootstrapDeliversOnlyPublicConnectionInput(t *testing.T) { - b := sandbox.Bootstrap{CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-secret"} - found := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PUT" || !strings.HasSuffix(r.URL.Path, "/containers/test/archive") { - t.Errorf("unexpected Docker operation %s", r.URL.Path) - } - tr := tar.NewReader(r.Body) - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Error(err) - break - } - if strings.Contains(h.Name, "auth.json") { - t.Error("provider wrote Runtime private storage") - } - if h.Name == "runtime/runtime-bootstrap.json" { - found = true - raw, err := io.ReadAll(tr) - if err != nil { - t.Error(err) - } - c, err := runtimebootstrap.Decode(raw) - if err != nil || c != b.RuntimeConnection() || h.Mode != 0600 || h.Uid != 1000 || h.Gid != 1000 { - t.Error("invalid launch input or permissions") - } - } - } - w.WriteHeader(200) - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - if err := (&Provider{client: c}).bootstrap(t.Context(), "test", b); err != nil { - t.Fatal(err) - } - if !found { - t.Fatal("missing Runtime input") - } -} diff --git a/services/agents-api/internal/sandbox/docker/command.go b/services/agents-api/internal/sandbox/docker/command.go deleted file mode 100644 index b2c0a744b..000000000 --- a/services/agents-api/internal/sandbox/docker/command.go +++ /dev/null @@ -1,91 +0,0 @@ -package docker - -import ( - "bytes" - "context" - "errors" - "io" - "path" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/moby/moby/api/pkg/stdcopy" - "github.com/moby/moby/client" -) - -// RunCommand is for trusted initialization only. Closing an exec attachment does -// not kill its process. On any uncertain outcome, the caller must reclaim the -// allocation with Kill instead of admitting native work or replaying the command. -func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, command sandbox.Command) (sandbox.CommandResult, error) { - var result sandbox.CommandResult - if len(command.Args) == 0 || len(command.Stdin) > sandbox.MaxCommandInputBytes || (command.Directory != "" && !path.IsAbs(command.Directory)) { - return result, sandbox.ErrInvalid - } - c, e := p.inspect(ctx, r) - if e != nil { - return result, e - } - if _, ok := ctx.Deadline(); !ok { - return result, sandbox.ErrInvalid - } - exec, e := p.client.ExecCreate(ctx, c.Container.ID, client.ExecCreateOptions{User: "1000:1000", Cmd: command.Args, WorkingDir: command.Directory, AttachStdin: command.Stdin != nil, AttachStdout: true, AttachStderr: true}) - if e != nil { - return result, e - } - attached, e := p.client.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{}) - if e != nil { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) - } - defer attached.Close() - written := make(chan error, 1) - if command.Stdin != nil { - go func() { - _, err := io.Copy(attached.Conn, bytes.NewReader(command.Stdin)) - if err == nil { - err = attached.CloseWrite() - } - written <- err - }() - } else { - written <- nil - } - stdout, stderr := &boundedBuffer{}, &boundedBuffer{} - done := make(chan error, 1) - go func() { _, e := stdcopy.StdCopy(stdout, stderr, attached.Reader); done <- e }() - select { - case e = <-done: - case <-ctx.Done(): - attached.Close() - <-done - e = ctx.Err() - } - if e != nil { - attached.Close() - <-written - return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) - } - select { - case e = <-written: - case <-ctx.Done(): - attached.Close() - <-written - e = ctx.Err() - } - if e != nil { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) - } - status, e := p.client.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{}) - if e != nil || status.Running { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) - } - return sandbox.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: status.ExitCode}, nil -} - -type boundedBuffer struct{ bytes.Buffer } - -func (b *boundedBuffer) Write(data []byte) (int, error) { - const max = 1024 * 1024 - if b.Len()+len(data) > max { - return 0, errors.New("initialization output exceeds 1 MiB") - } - return b.Buffer.Write(data) -} diff --git a/services/agents-api/internal/sandbox/docker/contract_test.go b/services/agents-api/internal/sandbox/docker/contract_test.go deleted file mode 100644 index 622f78171..000000000 --- a/services/agents-api/internal/sandbox/docker/contract_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package docker - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" - "github.com/google/uuid" - "github.com/moby/moby/client" -) - -// The script covers only native calls before a fault. Full Docker creation and -// bootstrap remain covered by the existing opt-in lifecycle and recovery tests. -type contractStep struct { - method, path string - status int - body any - fault contracttest.Fault -} - -func dockerContractFixture(t *testing.T, cancel context.CancelFunc, script func(*Provider, sandbox.Reference) []contractStep) contracttest.Fixture { - t.Helper() - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} - var mu sync.Mutex - var calls []string - var steps []contractStep - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/v1.52") - call := r.Method + " " + path - mu.Lock() - index := len(calls) - calls = append(calls, call) - mu.Unlock() - if index >= len(steps) || call != steps[index].method+" "+steps[index].path { - t.Errorf("unexpected native request: %s", call) - http.Error(w, "unexpected request", 500) - return - } - step := steps[index] - switch step.fault { - case contracttest.Canceled: - cancel() - return - case contracttest.TransportFailure: - connection, _, err := w.(http.Hijacker).Hijack() - if err != nil { - t.Error(err) - return - } - _ = connection.Close() - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(step.status) - if step.body != nil { - _ = json.NewEncoder(w).Encode(step.body) - } - })) - t.Cleanup(server.Close) - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = c.Close() }) - p, err := New(c, Config{InstallationID: uuid.NewString(), Image: "sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: "{}"}) - if err != nil { - t.Fatal(err) - } - steps = script(p, b.Reference) - want := make([]string, len(steps)) - for i, step := range steps { - want[i] = step.method + " " + step.path - } - return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { - mu.Lock() - defer mu.Unlock() - return append([]string(nil), calls...) - }, WantCalls: want} -} - -func TestProviderContract(t *testing.T) { - contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { - return dockerContractFixture(t, cancel, func(p *Provider, r sandbox.Reference) []contractStep { - inspect := "/containers/" + p.name(r) + "/json" - home, environment := "/volumes/"+p.name(r)+"-home", "/volumes/"+p.name(r)+"-environment" - missing := map[string]string{"message": "not found"} - owned := map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "running"}} - foreign := map[string]any{"Labels": map[string]string{"io.oac.tenant": uuid.NewString()}} - if s.Fault == contracttest.ForeignOwnership { - if s.Operation == "kill" { - // Verify every ownership check precedes any deletion, including volumes. - return []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, foreign, ""}} - } - return []contractStep{{http.MethodGet, inspect, 200, map[string]any{"Id": "foreign", "Config": foreign}, ""}} - } - switch s.Operation { - case "create": - return []contractStep{ - {http.MethodGet, inspect, 404, missing, ""}, - {http.MethodGet, home, 404, missing, ""}, - {http.MethodGet, environment, 404, missing, ""}, - {http.MethodPost, "/volumes/create", 0, nil, s.Fault}, - } - case "kill": - volume := map[string]any{"Labels": p.labels(r)} - steps := []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, volume, ""}, {http.MethodGet, environment, 200, volume, ""}} - if s.Fault == contracttest.CleanupFailure { - return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 204, nil, ""}, contractStep{http.MethodDelete, home, 500, map[string]string{"message": "cleanup failed"}, ""}) - } - return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 0, nil, s.Fault}) - default: - return []contractStep{{http.MethodGet, inspect, 0, nil, s.Fault}} - } - }) - }) -} - -func TestProviderContractObservation(t *testing.T) { - for _, operation := range []string{"inspect", "renew"} { - t.Run(operation, func(t *testing.T) { - f := dockerContractFixture(t, func() {}, func(p *Provider, r sandbox.Reference) []contractStep { - return []contractStep{{http.MethodGet, "/containers/" + p.name(r) + "/json", 200, map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "exited"}}, ""}} - }) - var got sandbox.Info - var err error - if operation == "renew" { - got, err = f.Provider.Renew(t.Context(), f.Bootstrap.Reference) - } else { - got, err = f.Provider.GetInfo(t.Context(), f.Bootstrap.Reference) - } - contracttest.AssertObservation(t, got, err, f.Bootstrap.Reference, "native-owned", "exited") - }) - } -} diff --git a/services/agents-api/internal/sandbox/docker/deployment.go b/services/agents-api/internal/sandbox/docker/deployment.go deleted file mode 100644 index bf3cf78cf..000000000 --- a/services/agents-api/internal/sandbox/docker/deployment.go +++ /dev/null @@ -1,30 +0,0 @@ -package docker - -import ( - "context" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/moby/moby/client" -) - -// Resource drift blocks managed readiness, but never ownership-based cleanup. -// Resolve the immutable selector through Docker because classic and containerd -// stores may identify the same exported image by different digests. -func (p *Provider) verifyConfiguration(ctx context.Context, actual client.ContainerInspectResult) error { - if p.config.Resources == nil { - return nil - } - resources := p.config.Resources - config := actual.Container.HostConfig - if config == nil || config.NanoCPUs != int64(resources.CPUs)*1000000000 || config.Memory != int64(resources.MemoryMiB)*1024*1024 { - return sandbox.ErrInvalid - } - image, err := p.client.ImageInspect(ctx, p.config.Image) - if err != nil { - return err - } - if image.ID == "" || actual.Container.Image != image.ID { - return sandbox.ErrInvalid - } - return nil -} diff --git a/services/agents-api/internal/sandbox/docker/deployment_test.go b/services/agents-api/internal/sandbox/docker/deployment_test.go deleted file mode 100644 index 0c041c2a3..000000000 --- a/services/agents-api/internal/sandbox/docker/deployment_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package docker - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/client" -) - -func TestManagedDriftRejectsBeforeBootstrapAndRetainsCleanup(t *testing.T) { - for _, drift := range []string{"cpu", "memory", "image"} { - t.Run(drift, func(t *testing.T) { - imageID := "sha256:" + strings.Repeat("a", 64) - present := false - volumes := map[string]map[string]any{} - var created struct { - container.Config - HostConfig container.HostConfig - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/v1.52") - w.Header().Set("Content-Type", "application/json") - switch { - case path == "/containers/create": - _ = json.NewDecoder(r.Body).Decode(&created) - if created.HostConfig.NanoCPUs != 3e9 || created.HostConfig.Memory != 4096*1024*1024 { - t.Error("create did not apply managed limits") - } - present = true - w.WriteHeader(201) - _, _ = io.WriteString(w, `{"Id":"runtime-id"}`) - case strings.HasPrefix(path, "/containers/") && strings.HasSuffix(path, "/json") && present: - host := created.HostConfig - actualImage := imageID - switch drift { - case "cpu": - host.NanoCPUs = 1e9 - case "memory": - host.Memory = 1024 * 1024 * 1024 - case "image": - actualImage = "sha256:" + strings.Repeat("b", 64) - } - _ = json.NewEncoder(w).Encode(map[string]any{"Id": "runtime-id", "Image": actualImage, "Config": created.Config, "HostConfig": host, "State": map[string]any{"Status": "created"}}) - case strings.HasPrefix(path, "/images/"): - _ = json.NewEncoder(w).Encode(map[string]string{"Id": imageID}) - case path == "/volumes/create": - var value map[string]any - _ = json.NewDecoder(r.Body).Decode(&value) - volumes[value["Name"].(string)] = value - _ = json.NewEncoder(w).Encode(value) - case strings.HasPrefix(path, "/volumes/") && volumes[strings.TrimPrefix(path, "/volumes/")] != nil: - name := strings.TrimPrefix(path, "/volumes/") - if r.Method == http.MethodDelete { - delete(volumes, name) - w.WriteHeader(204) - } else { - _ = json.NewEncoder(w).Encode(volumes[name]) - } - case r.Method == http.MethodDelete && strings.HasPrefix(path, "/containers/"): - present = false - w.WriteHeader(204) - case r.Method == http.MethodGet: - w.WriteHeader(404) - _, _ = io.WriteString(w, `{"message":"not found"}`) - default: - t.Errorf("unexpected bootstrap or mutation: %s %s", r.Method, path) - w.WriteHeader(500) - } - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - resources := sandbox.Resources{CPUs: 3, MemoryMiB: 4096} - p, err := New(c, Config{InstallationID: uuid.NewString(), Image: imageID, Network: "bridge", Seccomp: `{}`, Resources: &resources}) - if err != nil { - t.Fatal(err) - } - resources.CPUs = 9 - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "secret", NetworkAccess: "enabled"} - info, err := p.Create(t.Context(), b) - if !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("drift accepted", err) - } - if info.Reference != b.Reference || !info.CreateSettled || info.BootstrapComplete || info.ProviderID == "" || info.State == "absent" { - t.Fatal("pre-bootstrap rejection lost its creation settlement", info) - } - if _, err = p.GetInfo(t.Context(), b.Reference); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("drift readiness accepted", err) - } - if err = p.Kill(t.Context(), b.Reference); err != nil { - t.Fatal("drift prevented cleanup", err) - } - if present || len(volumes) != 0 { - t.Fatal("cleanup retained resources") - } - }) - } -} - -func TestNilManagedLimitsKeepCallerManagedDefaults(t *testing.T) { - options := runtimeContainerOptions(Config{}, "fixture", nil, nil) - if options.HostConfig.NanoCPUs != 2e9 || options.HostConfig.Memory != 2*1024*1024*1024 { - t.Fatal("caller-managed defaults changed") - } -} diff --git a/services/agents-api/internal/sandbox/docker/operations.go b/services/agents-api/internal/sandbox/docker/operations.go deleted file mode 100644 index 1a6b9dd2c..000000000 --- a/services/agents-api/internal/sandbox/docker/operations.go +++ /dev/null @@ -1,74 +0,0 @@ -package docker - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var _ sandbox.CheckpointProvider = (*Provider)(nil) -var _ sandbox.SelectionDiscoverer = (*Provider)(nil) -var _ sandbox.CredentialVerifier = (*Provider)(nil) -var _ runtimeobs.BatchSource = (*Provider)(nil) - -// Operations is this adapter's complete authored resource contract. -func Operations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "Suspend": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "Resume": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, - "Observe": {State: providercontract.Supported}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "docker_does_not_support_batch_observation"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "docker_does_not_support_selection_discovery"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "docker_does_not_support_credential_verification"}, - } -} -func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } -func (p *Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: Operations()["Initial"].Reason} -} -func (p *Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: Operations()["NewCompute"].Reason} -} -func (p *Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: Operations()["GetCompute"].Reason} -} -func (p *Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: Operations()["Suspend"].Reason} -} -func (p *Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: Operations()["Resume"].Reason} -} -func (p *Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: Operations()["KillCompute"].Reason} -} -func (p *Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: Operations()["DeleteSnapshot"].Reason} -} -func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: Operations()["RunCommandCompute"].Reason} -} -func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} -} -func (p *Provider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: Operations()["ObserveBatch"].Reason} -} -func (p *Provider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { - return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: Operations()["DiscoverSelection"].Reason} -} -func (p *Provider) VerifyCredential(context.Context, []sandbox.Reference) error { - return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: Operations()["VerifyCredential"].Reason} -} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go deleted file mode 100644 index 8ad977b63..000000000 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package docker is a thin SDK adapter for the dedicated, colocated Runtime. -package docker - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/containerd/errdefs" - "github.com/google/uuid" - "github.com/moby/moby/client" -) - -const labelPrefix = "io.oac." - -// Config is trusted operator configuration, never public Session input. The -// immutable image contains the qualified native profile and all Runtime binaries. -// Seccomp is JSON content, not a path on the Docker host. Network must provide -// trusted daemon/model connectivity; native tool network policy is in the image. -type Config struct { - InstallationID, Image, Network, Seccomp string - ExtraHosts []string - NestedSandbox bool - Resources *sandbox.Resources -} -type Provider struct { - client *client.Client - config Config -} - -var _ sandbox.SandboxProvider = (*Provider)(nil) - -func New(c *client.Client, config Config) (*Provider, error) { - if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { - return nil, sandbox.ErrInvalid - } - if config.Resources != nil { - if ValidateResources(*config.Resources) != nil { - return nil, sandbox.ErrInvalid - } - resources := *config.Resources - config.Resources = &resources - } - return &Provider{client: c, config: config}, nil -} -func validID(v string) bool { - u, e := uuid.Parse(v) - return e == nil && u != uuid.Nil && u.String() == v -} -func validReference(r sandbox.Reference) bool { - return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) -} -func (p *Provider) name(r sandbox.Reference) string { - h := sha256.Sum256([]byte(p.config.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) - return "oac-runtime-" + hex.EncodeToString(h[:16]) -} -func (p *Provider) labels(r sandbox.Reference) map[string]string { - return map[string]string{ - labelPrefix + "installation": p.config.InstallationID, labelPrefix + "tenant": r.TenantID, labelPrefix + "environment": r.EnvironmentID, labelPrefix + "allocation": r.AllocationID, - } -} -func (p *Provider) owns(labels map[string]string, r sandbox.Reference) bool { - for k, v := range p.labels(r) { - if labels[k] != v { - return false - } - } - return true -} -func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (client.ContainerInspectResult, error) { - if !validReference(r) { - return client.ContainerInspectResult{}, sandbox.ErrInvalid - } - v, e := p.client.ContainerInspect(ctx, p.name(r), client.ContainerInspectOptions{}) - if errdefs.IsNotFound(e) { - return v, sandbox.ErrNotFound - } - if e != nil { - return v, e - } - if v.Container.Config == nil || !p.owns(v.Container.Config.Labels, r) { - return v, sandbox.ErrOwnership - } - return v, nil -} -func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - info := sandbox.Info{Reference: r} - v, e := p.inspect(ctx, r) - if e != nil { - return info, e - } - if e = p.verifyConfiguration(ctx, v); e != nil { - return info, e - } - info.ProviderID = v.Container.ID - info.State = string(v.Container.State.Status) - info.BootstrapComplete = info.State == "running" - return info, nil -} - -// Renew observes Docker state. Docker has no lease; service-owned expiry must be -// managed durably by Core. Never synthesize an expiry or revive a stopped Runtime. -func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - return p.GetInfo(ctx, r) -} - -func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - info := sandbox.Info{Reference: b.Reference} - policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} - if policy.Validate() != nil || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { - return info, sandbox.ErrInvalid - } - if existing, e := p.GetInfo(ctx, b.Reference); e == nil { - return existing, sandbox.ErrExists - } else if !errors.Is(e, sandbox.ErrNotFound) { - return info, e - } - domains, err := json.Marshal(policy.Hosts()) - if err != nil { - return info, sandbox.ErrInvalid - } - name := p.name(b.Reference) - // Retained volumes without a container are partial or lost state, not an - // invitation to overwrite native history with a new bootstrap identity. - for _, suffix := range []string{"-home", "-environment"} { - v, err := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) - if err == nil { - if !p.owns(v.Volume.Labels, b.Reference) { - return info, sandbox.ErrOwnership - } - return info, sandbox.ErrExists - } - if !errdefs.IsNotFound(err) { - return info, err - } - } - for _, suffix := range []string{"-home", "-environment"} { - v, e := p.client.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: p.labels(b.Reference)}) - if e != nil { - return info, e - } - if !p.owns(v.Volume.Labels, b.Reference) { - return info, sandbox.ErrOwnership - } - } - options := runtimeContainerOptions(p.config, name, p.labels(b.Reference), []string{"OAC_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "OAC_RUNTIME_SESSION_ID=" + b.SessionID, "OAC_RUNTIME_NETWORK_ACCESS=" + policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS=" + string(domains)}) - options.Config.Cmd = []string{"connect", "--profile", "default", "--bootstrap-file", "/home/runtime/runtime-bootstrap.json"} - v, e := p.client.ContainerCreate(ctx, options) - if errdefs.IsConflict(e) { - return info, sandbox.ErrExists - } - if e != nil { - return info, e - } - info.ProviderID = v.ID - if p.config.Resources != nil { - actual, err := p.inspect(ctx, b.Reference) - if err != nil { - info.CreateSettled = true - return info, err - } - if err = p.verifyConfiguration(ctx, actual); err != nil { - // Container creation completed and bootstrap has not started. No - // outstanding mutation can recreate resources after owned cleanup. - info.CreateSettled = true - return info, err - } - } - // Any failure returns the retained allocation reference. The caller must Kill - // it, including on a lost acknowledgement. Never erase uncertain owner state. - if e = p.bootstrap(ctx, v.ID, b); e != nil { - return info, fmt.Errorf("runtime bootstrap: %w", e) - } - if _, e = p.client.ContainerStart(ctx, v.ID, client.ContainerStartOptions{}); e != nil { - return info, e - } - return p.GetInfo(ctx, b.Reference) -} - -// Kill is idempotent only for absence, not for errors or foreign ownership. It -// checks all resources before removing any and confirms removal of named volumes. -func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { - if !validReference(r) { - return sandbox.ErrInvalid - } - c, e := p.inspect(ctx, r) - if e != nil && !errors.Is(e, sandbox.ErrNotFound) { - return e - } - present := e == nil - name := p.name(r) - volumes := []string{} - for _, suffix := range []string{"-home", "-environment"} { - v, e := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) - if errdefs.IsNotFound(e) { - continue - } - if e != nil { - return e - } - if !p.owns(v.Volume.Labels, r) { - return sandbox.ErrOwnership - } - volumes = append(volumes, name+suffix) - } - if present { - if _, e = p.client.ContainerRemove(ctx, c.Container.ID, client.ContainerRemoveOptions{Force: true}); e != nil && !errdefs.IsNotFound(e) { - return e - } - } - for _, v := range volumes { - if _, e = p.client.VolumeRemove(ctx, v, client.VolumeRemoveOptions{}); e != nil && !errdefs.IsNotFound(e) { - return e - } - } - if _, e = p.inspect(ctx, r); !errors.Is(e, sandbox.ErrNotFound) { - if e == nil { - return errors.New("container removal unconfirmed") - } - return e - } - for _, v := range volumes { - if _, e = p.client.VolumeInspect(ctx, v, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { - if e == nil { - return errors.New("volume removal unconfirmed") - } - return e - } - } - return nil -} diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go deleted file mode 100644 index 16d859d26..000000000 --- a/services/agents-api/internal/sandbox/docker/provider_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package docker - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" - "github.com/containerd/errdefs" - "github.com/google/uuid" - "github.com/moby/moby/client" -) - -func TestProviderRejectsUnsafeOperatorConfiguration(t *testing.T) { - c, e := client.New() - if e != nil { - t.Fatal(e) - } - defer c.Close() - base := Config{InstallationID: uuid.NewString(), Image: "test@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`} - for _, change := range []func(*Config){func(c *Config) { c.Image = "mutable:latest" }, func(c *Config) { c.InstallationID = "" }, func(c *Config) { c.Network = "host" }, func(c *Config) { c.Network = "container:other" }, func(c *Config) { c.Seccomp = "" }} { - v := base - change(&v) - if _, e := New(c, v); !errors.Is(e, sandbox.ErrInvalid) { - t.Fatalf("accepted invalid configuration: %v", e) - } - } -} - -func TestBootstrapRequiresCompleteNetworkPolicyBeforeDockerEffects(t *testing.T) { - p := &Provider{} - for _, policy := range []sandbox.Bootstrap{ - {}, {NetworkAccess: "restricted"}, - {NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, - {NetworkAccess: "enabled", AllowedDomains: []string{"example.com"}}, - } { - policy.Reference = sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} - policy.SessionID, policy.DeviceID = uuid.NewString(), uuid.NewString() - policy.CoreURL, policy.Credential = "http://core.invalid/api/v1", "synthetic" - if _, err := p.Create(t.Context(), policy); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("invalid bootstrap reached Docker", err) - } - } -} - -// This optional Docker mechanism test uses a pinned fixture image whose entrypoint -// is sleep. It is not native/model acceptance; the real Runtime has separate checks. -func TestDockerProviderLifecycle(t *testing.T) { - image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") - if image == "" { - t.Skip("explicit Docker fixture image required") - } - seccomp, e := os.ReadFile("../../../deploy/codex/seccomp.json") - if e != nil { - t.Fatal(e) - } - c, e := client.New(client.FromEnv) - if e != nil { - t.Fatal(e) - } - defer c.Close() - installationID := uuid.NewString() - p, e := New(c, Config{InstallationID: installationID, Image: image, Network: "bridge", Seccomp: string(seccomp)}) - if e != nil { - t.Fatal(e) - } - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - bootstrap := func() sandbox.Bootstrap { - return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential", NetworkAccess: "enabled"} - } - b := bootstrap() - b.NetworkAccess, b.AllowedDomains = "restricted", []string{"Example.com", "api.example.com"} - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - if e := p.Kill(ctx, b.Reference); e != nil { - t.Error(e) - } - }) - t.Run("stdin concurrent output and EOF", func(t *testing.T) { - inputOwner := bootstrap() - if _, err := p.Create(ctx, inputOwner); err != nil { - t.Fatal(err) - } - defer func() { - cleanup, stop := context.WithTimeout(context.Background(), 20*time.Second) - defer stop() - if err := p.Kill(cleanup, inputOwner.Reference); err != nil { - t.Error(err) - } - }() - for _, data := range [][]byte{{}, bytes.Repeat([]byte{0, 255, 10, 1, 42}, 900000)} { - result, err := p.RunCommand(ctx, inputOwner.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "head -c 131072 /dev/zero; sha256sum"}, Stdin: data}) - digest := sha256.Sum256(data) - if err != nil || result.ExitCode != 0 || !strings.HasSuffix(result.Stdout, hex.EncodeToString(digest[:])+" -\n") || len(result.Stdout) != 131072+68 { - t.Fatalf("stdin/EOF failure: input=%d stdout=%d exit=%d error=%v", len(data), len(result.Stdout), result.ExitCode, err) - } - } - }) - info, e := p.Create(ctx, b) - contracttest.AssertObservation(t, info, e, b.Reference, "", "running") - resources, e := p.Observe(ctx, runtimeobs.Target{ - TenantID: b.TenantID, SessionID: b.SessionID, EnvironmentID: b.EnvironmentID, Mode: runtimeobs.ModeManaged, - Instance: runtimeobs.Instance{AllocationID: b.AllocationID, ProviderKey: installationID, DeviceID: b.DeviceID}, - }) - if e != nil || resources.StartedAt == nil || resources.CPUUsageSecondsTotal == nil || resources.MemoryUsageBytes == nil || resources.CPUCapacityCores == nil || resources.MemoryLimitBytes == nil { - t.Fatalf("bad resource observation: %+v %v", resources, e) - } - inspected, e := p.inspect(ctx, b.Reference) - if e != nil { - t.Fatal(e) - } - if strings.Contains(string(inspected.Raw), b.Credential) || inspected.Container.Config.User != "1000:1000" || !inspected.Container.HostConfig.ReadonlyRootfs || inspected.Container.HostConfig.Privileged { - t.Fatal("unsafe Docker configuration") - } - for _, value := range []string{"OAC_RUNTIME_NETWORK_ACCESS=restricted", `OAC_RUNTIME_ALLOWED_DOMAINS=["api.example.com","example.com"]`} { - found := false - for _, entry := range inspected.Container.Config.Env { - found = found || entry == value - } - if !found { - t.Fatalf("bootstrap lost network policy: %s", value) - } - } - changed := b - changed.Credential = "must-not-replace-existing" - if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { - t.Fatalf("duplicate not rejected: %v", e) - } - r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/runtime-bootstrap.json"}}) - if e != nil { - t.Fatal(e) - } - auth, decodeErr := runtimebootstrap.Decode([]byte(r.Stdout)) - if decodeErr != nil || auth.Credential != b.Credential || auth.DeviceID != b.DeviceID { - t.Fatal("bootstrap changed or malformed") - } - r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "printf retained > /environment/workspace/history; printf failed >&2; exit 7"}}) - if e != nil || r.ExitCode != 7 || r.Stderr != "failed" { - t.Fatalf("lost command status: %+v %v", r, e) - } - r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "set -eu; test \"$(cat /workspace/history)\" = retained; printf replaced > /environment/staging/replacement; mv /environment/staging/replacement /environment/workspace/history; cat /workspace/history"}}) - if e != nil || r.ExitCode != 0 || r.Stdout != "replaced" { - t.Fatal("public workspace view or atomic staging failed", e) - } - wrong := b.Reference - wrong.TenantID = uuid.NewString() - if _, e = p.GetInfo(ctx, wrong); !errors.Is(e, sandbox.ErrNotFound) { - t.Fatal("foreign allocation visible") - } - if e = p.Kill(ctx, wrong); e != nil { - t.Fatal(e) - } - if _, e = p.Renew(ctx, b.Reference); e != nil { - t.Fatal("wrong tenant removed the owner") - } - timeout := 1 - if _, e = c.ContainerRestart(ctx, info.ProviderID, client.ContainerRestartOptions{Timeout: &timeout}); e != nil { - t.Fatal(e) - } - r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/environment/workspace/history"}}) - if e != nil || r.Stdout != "replaced" { - t.Fatal("restart lost workspace") - } - r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "touch /cannot-write-root"}}) - if e != nil || r.ExitCode == 0 { - t.Fatal("root filesystem writable") - } - // Container loss must not trigger credential overwrite or state replacement. - if _, e = c.ContainerRemove(ctx, info.ProviderID, client.ContainerRemoveOptions{Force: true}); e != nil { - t.Fatal(e) - } - if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { - t.Fatalf("retained volumes reused: %v", e) - } - if e = p.Kill(ctx, b.Reference); e != nil { - t.Fatal(e) - } - for _, suffix := range []string{"-home", "-environment"} { - if _, e = c.VolumeInspect(ctx, p.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { - t.Fatal("named volume remains") - } - } - // A colliding resource with different ownership cannot be deleted, including - // when its container is absent after a partial creation. - foreign := bootstrap() - volume := p.name(foreign.Reference) + "-home" - if _, e = c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: volume, Labels: map[string]string{"fixture": "foreign"}}); e != nil { - t.Fatal(e) - } - t.Cleanup(func() { - _, e := c.VolumeRemove(context.Background(), volume, client.VolumeRemoveOptions{}) - if e != nil { - t.Error(e) - } - }) - if e = p.Kill(ctx, foreign.Reference); !errors.Is(e, sandbox.ErrOwnership) { - t.Fatal("foreign volume cleanup accepted") - } - if _, e = p.Create(ctx, foreign); !errors.Is(e, sandbox.ErrOwnership) { - t.Fatal("foreign volume bootstrap accepted") - } - // Closing initialization output is not process termination. Require explicit - // reclamation, without returning partial output as a successful command. - next := bootstrap() - defer p.Kill(context.Background(), next.Reference) - if _, e = p.Create(ctx, next); e != nil { - t.Fatal(e) - } - short, stop := context.WithTimeout(ctx, 100*time.Millisecond) - _, e = p.RunCommand(short, next.Reference, sandbox.Command{Args: []string{"sleep", "30"}}) - stop() - if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { - t.Fatalf("timeout classified as certain: %v", e) - } - if e = p.Kill(ctx, next.Reference); e != nil { - t.Fatal(e) - } -} diff --git a/services/agents-api/internal/sandbox/docker/recovery_test.go b/services/agents-api/internal/sandbox/docker/recovery_test.go deleted file mode 100644 index 36df2e0d9..000000000 --- a/services/agents-api/internal/sandbox/docker/recovery_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package docker - -import ( - "context" - "errors" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/containerd/errdefs" - "github.com/google/uuid" - "github.com/moby/moby/client" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -// These controlled transport faults use real Docker compute and volumes. They -// establish Provider recovery observations, not native or model acceptance. -func TestDockerProviderRecoveryObservations(t *testing.T) { - image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") - if image == "" { - t.Skip("explicit Docker fixture image required") - } - seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") - if err != nil { - t.Fatal(err) - } - for _, fault := range []string{"lost-start-response", "partial-volumes"} { - t.Run(fault, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) - defer cancel() - transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") - }} - defer transport.CloseIdleConnections() - var fired atomic.Bool - var creates atomic.Int32 - proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create") { - creates.Add(1) - if fault == "partial-volumes" { - fired.Store(true) - http.Error(w, `{"message":"injected before container create"}`, 500) - return - } - } - request := r.Clone(r.Context()) - request.RequestURI = "" - request.URL.Scheme = "http" - request.URL.Host = "docker" - request.Host = "docker" - response, e := transport.RoundTrip(request) - if e != nil { - http.Error(w, "Docker transport failed", 502) - return - } - defer response.Body.Close() - if fault == "lost-start-response" && r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start") && response.StatusCode == 204 && fired.CompareAndSwap(false, true) { - connection, _, e := w.(http.Hijacker).Hijack() - if e == nil { - _ = connection.Close() - } - return - } - for k, values := range response.Header { - for _, v := range values { - w.Header().Add(k, v) - } - } - w.WriteHeader(response.StatusCode) - _, _ = io.Copy(w, response.Body) - })) - defer proxy.Close() - c, e := client.New(client.WithHost(proxy.URL)) - if e != nil { - t.Fatal(e) - } - defer c.Close() - config := Config{InstallationID: uuid.NewString(), Image: image, Network: "bridge", Seccomp: string(seccomp)} - p, e := New(c, config) - if e != nil { - t.Fatal(e) - } - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-recovery-token", NetworkAccess: "enabled"} - direct, e := client.New(client.WithHost("unix:///var/run/docker.sock")) - if e != nil { - t.Fatal(e) - } - defer direct.Close() - recovered, e := New(direct, config) - if e != nil { - t.Fatal(e) - } - defer func() { - cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) - defer stop() - if e := recovered.Kill(cleanup, b.Reference); e != nil { - t.Error(e) - } - }() - if _, e := p.Create(ctx, b); e == nil || !fired.Load() { - t.Fatal("Create did not expose the injected uncertainty") - } - info, e := recovered.GetInfo(ctx, b.Reference) - if fault == "lost-start-response" { - if e != nil || info.State != "running" || !info.BootstrapComplete { - t.Fatalf("original allocation not recoverable: %+v %v", info, e) - } - } else { - if !errors.Is(e, sandbox.ErrNotFound) { - t.Fatalf("partial allocation observation: %v", e) - } - for _, suffix := range []string{"-home", "-environment"} { - if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); e != nil { - t.Fatal("missing container concealed missing volume fixture", e) - } - } - } - if creates.Load() != 1 { - t.Fatalf("Create was replayed %d times", creates.Load()) - } - if e := recovered.Kill(ctx, b.Reference); e != nil { - t.Fatal(e) - } - for _, suffix := range []string{"-home", "-environment"} { - if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { - t.Fatal("owned volume remains", e) - } - } - }) - } -} diff --git a/services/agents-api/internal/sandbox/docker/resources.go b/services/agents-api/internal/sandbox/docker/resources.go deleted file mode 100644 index f0f057de0..000000000 --- a/services/agents-api/internal/sandbox/docker/resources.go +++ /dev/null @@ -1,93 +0,0 @@ -package docker - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/client" -) - -var _ runtimeobs.Source = (*Provider)(nil) - -func (*Provider) ObservationProviderType() string { return "docker" } - -// Observe is read-only. Inspect verifies allocation ownership before Docker -// statistics are requested; it never renews or changes the container. -func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { - if target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID == "" { - return runtimeobs.Sample{}, sandbox.ErrInvalid - } - if target.Instance.ProviderKey != p.config.InstallationID { - return runtimeobs.Sample{}, sandbox.ErrOwnership - } - reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} - inspected, err := p.inspect(ctx, reference) - if errors.Is(err, sandbox.ErrNotFound) { - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - } - if err != nil { - return runtimeobs.Sample{}, err - } - if inspected.Container.State == nil || !inspected.Container.State.Running { - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - } - result, err := p.client.ContainerStats(ctx, inspected.Container.ID, client.ContainerStatsOptions{Stream: false, IncludePreviousSample: false}) - if err != nil { - return runtimeobs.Sample{}, fmt.Errorf("read Docker Runtime statistics: %w", err) - } - defer result.Body.Close() - var stats dockerStatsResponse - if err := json.NewDecoder(result.Body).Decode(&stats); err != nil { - return runtimeobs.Sample{}, fmt.Errorf("decode Docker Runtime statistics: %w", err) - } - return sampleFromDocker(inspected.Container, stats) -} - -type dockerStatsResponse struct { - Read time.Time `json:"read"` - CPUStats *struct { - CPUUsage *struct { - TotalUsage *uint64 `json:"total_usage"` - } `json:"cpu_usage"` - } `json:"cpu_stats"` - MemoryStats *struct { - Usage *uint64 `json:"usage"` - } `json:"memory_stats"` -} - -func sampleFromDocker(inspected container.InspectResponse, stats dockerStatsResponse) (runtimeobs.Sample, error) { - if inspected.State == nil || inspected.HostConfig == nil || !inspected.State.Running || stats.Read.IsZero() { - return runtimeobs.Sample{}, errors.New("incomplete Docker Runtime observation") - } - startedAt, err := time.Parse(time.RFC3339Nano, inspected.State.StartedAt) - if err != nil || startedAt.IsZero() || startedAt.After(stats.Read) { - return runtimeobs.Sample{}, errors.New("invalid Docker Runtime start time") - } - sample := runtimeobs.Sample{ - ObservedAt: stats.Read, - StartedAt: &startedAt, - } - if stats.CPUStats != nil && stats.CPUStats.CPUUsage != nil && stats.CPUStats.CPUUsage.TotalUsage != nil { - cpuUsage := float64(*stats.CPUStats.CPUUsage.TotalUsage) / float64(time.Second) - sample.CPUUsageSecondsTotal = &cpuUsage - } - if stats.MemoryStats != nil && stats.MemoryStats.Usage != nil { - memoryUsage := *stats.MemoryStats.Usage - sample.MemoryUsageBytes = &memoryUsage - } - if inspected.HostConfig.NanoCPUs > 0 { - capacity := float64(inspected.HostConfig.NanoCPUs) / 1_000_000_000 - sample.CPUCapacityCores = &capacity - } - if inspected.HostConfig.Memory > 0 { - limit := uint64(inspected.HostConfig.Memory) - sample.MemoryLimitBytes = &limit - } - return sample, nil -} diff --git a/services/agents-api/internal/sandbox/docker/resources_test.go b/services/agents-api/internal/sandbox/docker/resources_test.go deleted file mode 100644 index 8a21dbff6..000000000 --- a/services/agents-api/internal/sandbox/docker/resources_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package docker - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/google/uuid" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/client" -) - -func TestObserveVerifiesOwnershipThenReadsOneShotStats(t *testing.T) { - installationID := uuid.NewString() - target := runtimeobs.Target{ - TenantID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), Mode: runtimeobs.ModeManaged, - Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), ProviderKey: installationID, DeviceID: uuid.NewString()}, - } - observed := time.Now().UTC().Truncate(time.Microsecond) - started := observed.Add(-time.Minute) - statsRead := false - omitMeasurements := false - running := true - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/json"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "Id": "container-id", - "State": map[string]any{"Status": "running", "Running": running, "StartedAt": started.Format(time.RFC3339Nano)}, - "HostConfig": map[string]any{"NanoCpus": 2_000_000_000, "Memory": 2048}, - "Config": map[string]any{"Labels": map[string]string{ - labelPrefix + "installation": installationID, - labelPrefix + "tenant": target.TenantID, - labelPrefix + "environment": target.EnvironmentID, - labelPrefix + "allocation": target.Instance.AllocationID, - }}, - }) - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/containers/container-id/stats"): - if r.URL.Query().Get("stream") != "false" || r.URL.Query().Get("one-shot") != "true" { - t.Errorf("stats request was not one-shot: %s", r.URL.RawQuery) - } - statsRead = true - if omitMeasurements { - _ = json.NewEncoder(w).Encode(map[string]any{"read": observed}) - return - } - _ = json.NewEncoder(w).Encode(container.StatsResponse{ - Read: observed, - CPUStats: container.CPUStats{CPUUsage: container.CPUUsage{TotalUsage: 1_500_000_000}}, - MemoryStats: container.MemoryStats{Usage: 1024}, - }) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL)) - if err != nil { - t.Fatal(err) - } - defer c.Close() - p, err := New(c, Config{InstallationID: installationID, Image: "fixture@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`}) - if err != nil { - t.Fatal(err) - } - sample, err := p.Observe(t.Context(), target) - if err != nil || !statsRead || sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 1.5 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 1024 { - t.Fatalf("bad one-shot observation: %+v %v stats=%v", sample, err, statsRead) - } - omitMeasurements = true - missing, err := p.Observe(t.Context(), target) - if err != nil || missing.CPUUsageSecondsTotal != nil || missing.MemoryUsageBytes != nil || missing.CPUCapacityCores == nil || missing.MemoryLimitBytes == nil { - t.Fatalf("missing Docker measurements became zero: %+v %v", missing, err) - } - running = false - statsRead = false - if _, err := p.Observe(t.Context(), target); !errors.Is(err, runtimeobs.ErrNotRunning) || statsRead { - t.Fatalf("stopped Runtime was not classified before stats: %v stats=%v", err, statsRead) - } - running = true - foreign := target - foreign.Instance.ProviderKey = uuid.NewString() - statsRead = false - if _, err := p.Observe(t.Context(), foreign); err == nil || statsRead { - t.Fatal("foreign provider identity reached Docker stats") - } -} - -func TestSampleFromDockerPreservesObservedZeroAndConfiguredCapacity(t *testing.T) { - observed := time.Date(2026, 9, 22, 1, 2, 3, 0, time.UTC) - started := observed.Add(-5 * time.Minute) - zero := uint64(0) - sample, err := sampleFromDocker(container.InspectResponse{ - State: &container.State{Running: true, StartedAt: started.Format(time.RFC3339Nano)}, - HostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: 2_000_000_000, Memory: 2 * 1024 * 1024 * 1024}}, - }, testDockerStats(observed, &zero, &zero)) - if err != nil { - t.Fatal(err) - } - if sample.ObservedAt != observed || sample.StartedAt == nil || !sample.StartedAt.Equal(started) { - t.Fatalf("lost Docker observation time: %+v", sample) - } - if sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 0 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 0 { - t.Fatalf("observed zero became unavailable: %+v", sample) - } - if sample.CPUCapacityCores == nil || *sample.CPUCapacityCores != 2 || sample.MemoryLimitBytes == nil || *sample.MemoryLimitBytes != 2*1024*1024*1024 { - t.Fatalf("lost configured capacity: %+v", sample) - } -} - -func TestSampleFromDockerNormalizesCumulativeCPU(t *testing.T) { - observed := time.Now().UTC() - started := observed.Add(-time.Hour) - cpu, memory := uint64(2_500_000_000), uint64(4096) - sample, err := sampleFromDocker(container.InspectResponse{ - State: &container.State{Running: true, StartedAt: started.Format(time.RFC3339Nano)}, - HostConfig: &container.HostConfig{}, - }, testDockerStats(observed, &cpu, &memory)) - if err != nil { - t.Fatal(err) - } - if sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 2.5 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 4096 { - t.Fatalf("bad Docker normalization: %+v", sample) - } - if sample.CPUCapacityCores != nil || sample.MemoryLimitBytes != nil { - t.Fatalf("invented unconfigured capacity: %+v", sample) - } -} - -func TestSampleFromDockerRejectsIncompleteState(t *testing.T) { - observed := time.Now().UTC() - for _, inspected := range []container.InspectResponse{ - {}, - {State: &container.State{Running: false}, HostConfig: &container.HostConfig{}}, - {State: &container.State{Running: true, StartedAt: "invalid"}, HostConfig: &container.HostConfig{}}, - } { - if _, err := sampleFromDocker(inspected, dockerStatsResponse{Read: observed}); err == nil { - t.Fatal("accepted incomplete Docker state") - } - } -} - -func testDockerStats(observed time.Time, cpu, memory *uint64) dockerStatsResponse { - stats := dockerStatsResponse{Read: observed} - if cpu != nil { - stats.CPUStats = &struct { - CPUUsage *struct { - TotalUsage *uint64 `json:"total_usage"` - } `json:"cpu_usage"` - }{CPUUsage: &struct { - TotalUsage *uint64 `json:"total_usage"` - }{TotalUsage: cpu}} - } - if memory != nil { - stats.MemoryStats = &struct { - Usage *uint64 `json:"usage"` - }{Usage: memory} - } - return stats -} diff --git a/services/agents-api/internal/sandbox/e2b/contract_test.go b/services/agents-api/internal/sandbox/e2b/contract_test.go deleted file mode 100644 index f4d55e827..000000000 --- a/services/agents-api/internal/sandbox/e2b/contract_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package e2b - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" - "github.com/google/uuid" - "testing" -) - -type contractCaller func(context.Context, Request) (Response, error) - -func (f contractCaller) Call(ctx context.Context, q Request) (Response, error) { return f(ctx, q) } - -func TestProviderContract(t *testing.T) { - contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { - p, _, r := fixture(t) - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} - var calls []string - p.caller = contractCaller(func(ctx context.Context, q Request) (Response, error) { - calls = append(calls, q.Operation) - if q.Reference != r { - t.Fatal("native request lost allocation identity") - } - switch s.Fault { - case contracttest.Canceled: - cancel() - return Response{}, ctx.Err() - case contracttest.ForeignOwnership: - if s.Operation == "kill" { - return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil - } - foreign := r - foreign.AllocationID = uuid.NewString() - return Response{Version: ProtocolVersion, Info: &sandbox.Info{Reference: foreign, ProviderID: "foreign", State: "running", CreateSettled: true, BootstrapComplete: true}}, nil - case contracttest.CleanupFailure: - return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil - default: - return Response{}, errors.New("lost helper response") - } - }) - return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{s.Operation}} - }) -} - -func TestProviderContractObservation(t *testing.T) { - p, f, r := fixture(t) - f.response.Info = &sandbox.Info{Reference: r, ProviderID: "native-owned", State: "running", CreateSettled: true, BootstrapComplete: true} - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} - got, err := p.Create(bounded(t), b) - contracttest.AssertObservation(t, got, err, r, "native-owned", "running") - got, err = p.GetInfo(bounded(t), r) - contracttest.AssertObservation(t, got, err, r, "native-owned", "running") - got, err = p.Renew(bounded(t), r) - contracttest.AssertObservation(t, got, err, r, "native-owned", "running") -} diff --git a/services/agents-api/internal/sandbox/e2b/deployment.go b/services/agents-api/internal/sandbox/e2b/deployment.go deleted file mode 100644 index 97eb36898..000000000 --- a/services/agents-api/internal/sandbox/e2b/deployment.go +++ /dev/null @@ -1,137 +0,0 @@ -package e2b - -import ( - "context" - "errors" - "math" - "strings" - "unicode" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func Policy() sandbox.DeploymentPolicy { - return sandbox.DeploymentPolicy{RuntimeError: "E2B Runtime is selected by its immutable template build"} -} - -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } - -func ValidateConfiguration(c *sandbox.E2BConfiguration) error { - if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { - return sandbox.ErrInvalid - } - if _, _, err := NormalizeEndpoint(c.APIURL, c.Domain); err != nil { - return sandbox.ErrInvalid - } - template, build, ok := strings.Cut(c.Template, ":") - id, err := uuid.Parse(build) - if !ok || template == "" || len(template) > 128 || err != nil || id == uuid.Nil || id.String() != build { - return sandbox.ErrInvalid - } - for _, c := range template { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { - return sandbox.ErrInvalid - } - } - return nil -} - -// NormalizeSelection accepts omitted candidate resources only until live build -// discovery. Persistence requires ValidateSpecification after preparation. -func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { - if s.Resources == (sandbox.Resources{}) { - if s.Runtime != nil { - return s, &sandbox.ValidationError{Param: "runtime", Message: sandbox.ErrInvalid.Error() + ": " + Policy().RuntimeError} - } - } else if err := ValidateSpecification(s.DeploymentSpec); err != nil { - return s, err - } - if err := ValidateConfiguration(s.E2B); err != nil { - return s, err - } - c := *s.E2B - c.APIURL, c.Domain, _ = NormalizeEndpoint(c.APIURL, c.Domain) - s.E2B = &c - return s, nil -} - -func WithTemplateBuild(input sandbox.Selection, build *sandbox.TemplateBuild) sandbox.Selection { - if input.E2B != nil && build != nil { - c, b := *input.E2B, *build - if input.Resources == (sandbox.Resources{}) { - input.Resources = sandbox.Resources{CPUs: uint32(b.CPUs), MemoryMiB: uint32(b.MemoryMiB)} - } - c.TemplateBuild = &b - input.E2B = &c - } - return input -} - -// DiscoverSelection checks the immutable native build without allocating compute. -func (p *Provider) DiscoverSelection(ctx context.Context, s sandbox.Selection) (sandbox.Selection, error) { - build, err := p.ValidateDeployment(ctx) - if err != nil { - if errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) { - return s, err - } - if errors.Is(err, sandbox.ErrInvalid) { - return s, ErrTemplateInvalid - } - return s, ErrRequestUnconfirmed - } - recorded := &sandbox.TemplateBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} - if build.RootDiskMiB != nil && *build.RootDiskMiB <= math.MaxInt32 { - disk := int32(*build.RootDiskMiB) - recorded.RootDiskMiB = &disk - } - s = WithTemplateBuild(s, recorded) - if err := ValidateSpecification(s.DeploymentSpec); err != nil { - return s, &sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"} - } - return s, nil -} - -// RestoreSelection normalizes stored connection fields without admitting a new -// template or requiring remote availability for retained-resource cleanup. -func RestoreSelection(s sandbox.Selection) (sandbox.Selection, error) { - if s.E2B == nil { - return s, sandbox.ErrInvalid - } - c := *s.E2B - var err error - c.APIURL, c.Domain, err = NormalizeEndpoint(c.APIURL, c.Domain) - s.E2B = &c - return s, err -} -func ResolveChange(next, previous sandbox.Selection) sandbox.Selection { - if next.E2B == nil || previous.E2B == nil { - return next - } - c := *next.E2B - c.ReplaceCredential = c.ReplaceCredential || c.APIKey != "" - if c.APIURL == "" && c.Domain == "" { - c.APIURL, c.Domain = previous.E2B.APIURL, previous.E2B.Domain - } - if c.APIKey == "" && !c.ReplaceCredential { - c.APIKey = previous.E2B.APIKey - } - if c.Template == previous.E2B.Template && next.Resources == (sandbox.Resources{}) { - next.Resources = previous.Resources - } - next.E2B = &c - return next -} - -func ReplaceCredential(owner, candidate sandbox.Selection) sandbox.Selection { - if owner.E2B != nil && candidate.E2B != nil { - c := *owner.E2B - c.APIKey = candidate.E2B.APIKey - owner.E2B = &c - } - return owner -} -func CredentialRequiresReset(err error) bool { - return errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) -} diff --git a/services/agents-api/internal/sandbox/e2b/deployment_test.go b/services/agents-api/internal/sandbox/e2b/deployment_test.go deleted file mode 100644 index 6e2f02ed6..000000000 --- a/services/agents-api/internal/sandbox/e2b/deployment_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package e2b - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func TestDeploymentValidationIsBoundedAndNeedsExplicitProof(t *testing.T) { - p, caller, _ := fixture(t) - resources := sandbox.Resources{CPUs: 2, MemoryMiB: 2048} - config := p.config - config.Resources = &resources - p, err := NewWithCaller(config, caller) - if err != nil { - t.Fatal(err) - } - resources.CPUs = 9 - disk := uint32(24063) - caller.response.DeploymentValid = true - caller.response.TemplateBuild = &TemplateBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} - build, err := p.ValidateDeployment(context.Background()) - if err != nil || build.CPUs != 2 || build.MemoryMiB != 2048 || build.RootDiskMiB == nil || *build.RootDiskMiB != disk || build.Status != "ready" { - t.Fatalf("validated build = %+v %v", build, err) - } - q := caller.requests[0] - if q.Operation != "validate_deployment" || q.Reference != (sandbox.Reference{}) || q.Bootstrap != nil || q.Config.Resources.CPUs != 2 || time.Until(q.Deadline) > 30*time.Second || time.Until(q.Deadline) <= 0 { - t.Fatalf("invalid validation request %+v", q) - } - for _, response := range []Response{ - {Version: ProtocolVersion, TemplateBuild: caller.response.TemplateBuild}, - {Version: ProtocolVersion, DeploymentValid: true}, - {Version: ProtocolVersion, DeploymentValid: true, TemplateBuild: &TemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 2048}}, - } { - caller.response = response - if _, err = p.ValidateDeployment(t.Context()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { - t.Fatal("missing proof accepted", err) - } - } - config.Resources = &sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192} - if _, err = NewWithCaller(config, caller); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("unsupported disk accepted", err) - } -} diff --git a/services/agents-api/internal/sandbox/e2b/observations.go b/services/agents-api/internal/sandbox/e2b/observations.go deleted file mode 100644 index e083bef97..000000000 --- a/services/agents-api/internal/sandbox/e2b/observations.go +++ /dev/null @@ -1,162 +0,0 @@ -package e2b - -import ( - "context" - "math" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var ( - _ runtimeobs.Source = (*Provider)(nil) - _ runtimeobs.BatchSource = (*Provider)(nil) -) - -// maxClockLead tolerates an E2B metrics timestamp slightly ahead of Core's -// clock. A larger lead is treated as an unavailable sample, never as fresh data. -const maxClockLead = 30 * time.Second - -// Observation is one allocation's latest E2B metrics point. Status is -// observed, not_running, unavailable or ownership; only observed carries -// values. A malformed E2B point is unavailable for its row only. Values keep E2B's units: CPUUsedPct is a percentage of all -// CPUCount cores, and memory and disk are in bytes. -type Observation struct { - sandbox.Reference - Status string - ObservedAt, StartedAt *time.Time `json:",omitempty"` - CPUCount, CPUUsedPct *float64 `json:",omitempty"` - MemUsed, MemTotal *uint64 `json:",omitempty"` - DiskUsed, DiskTotal *uint64 `json:",omitempty"` -} - -func (*Provider) ObservationProviderType() string { return "e2b" } - -// Observe reads one allocation through the same helper request as ObserveBatch. -func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { - results, _ := p.ObserveBatch(ctx, []runtimeobs.Target{target}) - return results[0].Sample, results[0].Err -} - -// ObserveBatch reads up to runtimeobs.MaxBatchTargets allocations with one -// helper request. The helper takes sandbox IDs from its private receipts, -// confirms each running sandbox by its allocation labels and reads E2B's batch -// metrics once. It never connects to, renews or changes a sandbox. -func (p *Provider) ObserveBatch(ctx context.Context, targets []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - results := make([]runtimeobs.BatchResult, len(targets)) - references := make([]sandbox.Reference, 0, len(targets)) - positions := make([]int, 0, len(targets)) - for index, target := range targets { - reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} - switch { - case target.Mode != runtimeobs.ModeManaged || !validReference(reference) || len(targets) > runtimeobs.MaxBatchTargets: - results[index].Err = sandbox.ErrInvalid - case target.Instance.ProviderKey != p.config.InstallationID: - results[index].Err = sandbox.ErrOwnership - default: - references = append(references, reference) - positions = append(positions, index) - } - } - if len(references) == 0 { - return results, nil - } - observations, err := p.observe(ctx, references) - now := p.now() - for offset, index := range positions { - if err != nil { - results[index].Err = err - continue - } - results[index].Sample, results[index].Err = sampleFromObservation(observations[offset], now) - } - return results, nil -} - -func (p *Provider) observe(ctx context.Context, references []sandbox.Reference) ([]Observation, error) { - deadline, ok := ctx.Deadline() - if !ok { - return nil, sandbox.ErrInvalid - } - if err := ctx.Err(); err != nil { - return nil, err - } - out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: "observe", Config: p.config, References: references, Deadline: deadline}) - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - if err != nil || out.Version != ProtocolVersion || out.Info != nil || out.Command != nil || out.DeploymentValid || out.TemplateBuild != nil { - return nil, runtimeobs.ErrUnavailable - } - switch out.ErrorCode { - case "": - case "invalid": - return nil, sandbox.ErrInvalid - case "ownership": - return nil, sandbox.ErrOwnership - default: - // E2B API failures, including a rejected credential, leave rows unavailable. - return nil, runtimeobs.ErrUnavailable - } - if len(out.Observations) != len(references) { - return nil, sandbox.ErrInvalid - } - for index, observation := range out.Observations { - if observation.Reference != references[index] { - return nil, sandbox.ErrOwnership - } - } - return out.Observations, nil -} - -// sampleFromObservation maps E2B's latest point to the provider-neutral -// sample. E2B reports no cumulative CPU time, so usage seconds stay nil. -func sampleFromObservation(observation Observation, now time.Time) (runtimeobs.Sample, error) { - switch observation.Status { - case "observed": - case "not_running": - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - case "unavailable": - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - case "ownership": - return runtimeobs.Sample{}, sandbox.ErrOwnership - default: - // An unknown status breaks Core's own helper protocol. - return runtimeobs.Sample{}, sandbox.ErrInvalid - } - // Malformed provider data leaves this row unavailable, not the whole page. - if observation.ObservedAt == nil || observation.StartedAt == nil || observation.CPUCount == nil || observation.CPUUsedPct == nil || - observation.MemUsed == nil || observation.MemTotal == nil || !finite(*observation.CPUCount) || *observation.CPUCount <= 0 || - !finite(*observation.CPUUsedPct) || *observation.CPUUsedPct < 0 || *observation.MemTotal == 0 { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - // E2B timestamps use the provider's clock. Record a small lead at Core's - // time so that a fresh point is not rejected as a future sample. - observedAt, startedAt := *observation.ObservedAt, *observation.StartedAt - if lead := observedAt.Sub(now); lead > 0 { - if lead > maxClockLead { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - observedAt = now - } - if startedAt.After(observedAt) { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - ratio, capacity := *observation.CPUUsedPct/100, *observation.CPUCount - memoryUsage, memoryLimit := *observation.MemUsed, *observation.MemTotal - sample := runtimeobs.Sample{ - ObservedAt: observedAt, StartedAt: &startedAt, - CPUUtilizationRatio: &ratio, CPUCapacityCores: &capacity, - MemoryUsageBytes: &memoryUsage, MemoryLimitBytes: &memoryLimit, - } - // Templates with an envd older than E2B's disk metrics report no disk - // capacity; keep disk unknown rather than an observed zero. - if observation.DiskUsed != nil && observation.DiskTotal != nil && *observation.DiskTotal > 0 { - diskUsage, diskLimit := *observation.DiskUsed, *observation.DiskTotal - sample.DiskUsageBytes, sample.DiskLimitBytes = &diskUsage, &diskLimit - } - return sample, nil -} - -func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } diff --git a/services/agents-api/internal/sandbox/e2b/observations_test.go b/services/agents-api/internal/sandbox/e2b/observations_test.go deleted file mode 100644 index 771e3dd23..000000000 --- a/services/agents-api/internal/sandbox/e2b/observations_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package e2b - -import ( - "errors" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func TestObserveBatchMapsMetricsAndKeepsUnmeasuredValuesNull(t *testing.T) { - p, caller, running := fixture(t) - stopped := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} - now := time.Date(2026, 9, 25, 10, 31, 37, 0, time.UTC) - p.now = func() time.Time { return now } - started, observed := now.Add(-3*time.Second), now.Add(time.Second) // E2B's clock leads by one second. - count, percent, memoryUsed, memoryTotal, diskUsed, diskTotal := 2.0, 19.55, uint64(183836672), uint64(2079141888), uint64(1593188352), uint64(23511863296) - caller.response.Observations = []Observation{ - {Reference: running, Status: "observed", ObservedAt: &observed, StartedAt: &started, CPUCount: &count, CPUUsedPct: &percent, - MemUsed: &memoryUsed, MemTotal: &memoryTotal, DiskUsed: &diskUsed, DiskTotal: &diskTotal}, - {Reference: stopped, Status: "not_running"}, - } - targets := []runtimeobs.Target{} - for _, reference := range []sandbox.Reference{running, stopped} { - targets = append(targets, runtimeobs.Target{TenantID: reference.TenantID, EnvironmentID: reference.EnvironmentID, Mode: runtimeobs.ModeManaged, - Instance: runtimeobs.Instance{AllocationID: reference.AllocationID, ProviderKey: p.config.InstallationID}}) - } - results, err := p.ObserveBatch(bounded(t), targets) - if err != nil || len(caller.requests) != 1 || caller.requests[0].Operation != "observe" || len(caller.requests[0].References) != 2 || - caller.requests[0].References[1] != stopped || caller.requests[0].Deadline.IsZero() { - t.Fatalf("batch was not one bounded helper request: err=%v %+v", err, caller.requests) - } - sample := results[0].Sample - if results[0].Err != nil || !sample.ObservedAt.Equal(now) || !sample.StartedAt.Equal(started) || - *sample.CPUUtilizationRatio != .1955 || *sample.CPUCapacityCores != 2 || sample.CPUUsageSecondsTotal != nil || - *sample.MemoryUsageBytes != memoryUsed || *sample.MemoryLimitBytes != memoryTotal || - *sample.DiskUsageBytes != diskUsed || *sample.DiskLimitBytes != diskTotal { - t.Fatalf("metrics were not mapped: %+v %v", sample, results[0].Err) - } - if !errors.Is(results[1].Err, runtimeobs.ErrNotRunning) { - t.Fatalf("absent sandbox = %v", results[1].Err) - } - - caller.response.Observations[0].DiskTotal = nil - results, _ = p.ObserveBatch(bounded(t), targets) - if results[0].Err != nil || results[0].Sample.DiskUsageBytes != nil || results[0].Sample.DiskLimitBytes != nil { - t.Fatalf("unreported disk was not null: %+v %v", results[0].Sample, results[0].Err) - } - caller.response.Observations[0].MemTotal = nil - results, _ = p.ObserveBatch(bounded(t), targets) - if !errors.Is(results[0].Err, runtimeobs.ErrUnavailable) || !errors.Is(results[1].Err, runtimeobs.ErrNotRunning) { - t.Fatalf("a malformed point affected more than its row: %+v", results) - } - caller.response.ErrorCode = "unconfirmed" - results, _ = p.ObserveBatch(bounded(t), targets) - if !errors.Is(results[0].Err, runtimeobs.ErrUnavailable) || !errors.Is(results[1].Err, runtimeobs.ErrUnavailable) { - t.Fatalf("E2B failure was not unavailable: %+v", results) - } -} diff --git a/services/agents-api/internal/sandbox/e2b/operations.go b/services/agents-api/internal/sandbox/e2b/operations.go deleted file mode 100644 index ee303792f..000000000 --- a/services/agents-api/internal/sandbox/e2b/operations.go +++ /dev/null @@ -1,65 +0,0 @@ -package e2b - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var _ sandbox.CheckpointProvider = (*Provider)(nil) -var _ sandbox.SelectionDiscoverer = (*Provider)(nil) -var _ sandbox.CredentialVerifier = (*Provider)(nil) -var _ runtimeobs.BatchSource = (*Provider)(nil) - -// Operations is this adapter's complete authored resource contract. -func Operations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "Suspend": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "Resume": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "Observe": {State: providercontract.Supported}, - "ObserveBatch": {State: providercontract.Supported}, - "DiscoverSelection": {State: providercontract.Supported}, - "VerifyCredential": {State: providercontract.Supported}, - } -} -func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } -func (p *Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: Operations()["Initial"].Reason} -} -func (p *Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: Operations()["NewCompute"].Reason} -} -func (p *Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: Operations()["GetCompute"].Reason} -} -func (p *Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: Operations()["Suspend"].Reason} -} -func (p *Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: Operations()["Resume"].Reason} -} -func (p *Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: Operations()["KillCompute"].Reason} -} -func (p *Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: Operations()["DeleteSnapshot"].Reason} -} -func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: Operations()["RunCommandCompute"].Reason} -} -func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} -} diff --git a/services/agents-api/internal/sandbox/e2b/process.go b/services/agents-api/internal/sandbox/e2b/process.go deleted file mode 100644 index 3d6eeab6e..000000000 --- a/services/agents-api/internal/sandbox/e2b/process.go +++ /dev/null @@ -1,86 +0,0 @@ -package e2b - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -// ProcessCaller retains and drains an outstanding helper after caller timeout. -// The helper keeps its allocation lock until its bounded SDK operation settles. -var errHelperNotStarted = errors.New("helper did not start") - -type ProcessCaller struct{ Fence *sandbox.CallFence } - -func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { - data, err := json.Marshal(q) - if err != nil || len(data) > MaxRequestBytes { - return Response{}, errHelperNotStarted - } - cmd := exec.Command(q.Config.Binary) - cmd.Stdin = bytes.NewReader(data) - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - if key == "HOME" || key == "PATH" || key == "TMPDIR" || key == "LANG" || key == "SSL_CERT_FILE" || key == "SSL_CERT_DIR" { - cmd.Env = append(cmd.Env, entry) - } - } - cmd.Env = append(cmd.Env, "PYTHONNOUSERSITE=1", "PYTHONDONTWRITEBYTECODE=1") - stdout, stderr := &limitBuffer{limit: MaxResponseBytes}, &limitBuffer{limit: MaxOutputBytes} - cmd.Stdout, cmd.Stderr = stdout, stderr - if ctx.Err() != nil { - return Response{}, errHelperNotStarted - } - if cmd.Start() != nil { - return Response{}, errHelperNotStarted - } - finished := func() {} - if p.Fence != nil { - finished = p.Fence.ChildStarted() - } - done := make(chan error, 1) - go func() { err := cmd.Wait(); finished(); done <- err }() - select { - case <-ctx.Done(): - return Response{}, ctx.Err() - case err = <-done: - if err != nil || stdout.exceeded || stderr.exceeded { - return Response{}, errors.New("helper result unconfirmed") - } - } - var out Response - decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) - decoder.DisallowUnknownFields() - if decoder.Decode(&out) != nil { - return Response{}, errors.New("invalid helper response") - } - var extra any - if decoder.Decode(&extra) != io.EOF { - return Response{}, errors.New("trailing helper response") - } - return out, nil -} - -type limitBuffer struct { - bytes.Buffer - limit int - exceeded bool -} - -func (b *limitBuffer) Write(data []byte) (int, error) { - n := len(data) - left := b.limit - b.Len() - if n > left { - b.exceeded = true - data = data[:left] - } - _, _ = b.Buffer.Write(data) - return n, nil -} diff --git a/services/agents-api/internal/sandbox/e2b/process_test.go b/services/agents-api/internal/sandbox/e2b/process_test.go deleted file mode 100644 index 918b70a67..000000000 --- a/services/agents-api/internal/sandbox/e2b/process_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package e2b - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func TestProcessWireRejectsUnknownAndTrailingOutput(t *testing.T) { - for _, output := range []string{`{"Version":1,"Secret":"private"}`, `{"Version":1} {"Version":1}`} { - root := t.TempDir() - binary := filepath.Join(root, "helper") - if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+output+"'\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := (&ProcessCaller{}).Call(bounded(t), Request{Config: Config{Binary: binary}}); err == nil || strings.Contains(err.Error(), "private") { - t.Fatal("invalid helper response accepted or leaked", err) - } - } -} -func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) { - root := t.TempDir() - binary := filepath.Join(root, "helper") - marker := filepath.Join(root, "settled") - // The private test path contains no shell syntax; the real adapter never puts - // credentials or request contents in argv or inherited environment. - script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":1}'\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer cancel() - if _, err := (&ProcessCaller{}).Call(ctx, Request{Config: Config{Binary: binary}}); err == nil { - t.Fatal("timeout not reported") - } - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(marker); err == nil { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("helper was killed before it could settle") -} -func TestEnvironmentDropsProviderSelectorsAndCredentials(t *testing.T) { - root := t.TempDir() - binary := filepath.Join(root, "helper") - script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":1}'\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - t.Setenv("E2B_API_KEY", "secret") - t.Setenv("E2B_API_URL", "https://wrong.example") - t.Setenv("PRIVATE_MODEL_KEY", "secret") - if _, err := (&ProcessCaller{}).Call(bounded(t), Request{Config: Config{Binary: binary}}); err != nil { - t.Fatal(err) - } -} - -func TestCredentialFenceWaitsForActualHelperExitAfterCancellation(t *testing.T) { - root := t.TempDir() - binary := filepath.Join(root, "helper") - marker := filepath.Join(root, "started") - finish := filepath.Join(root, "finish") - script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":1}'\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - fence := &sandbox.CallFence{} - ctx, cancel := context.WithCancel(t.Context()) - entered, err := fence.Enter(ctx) - if err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { - _, err := (&ProcessCaller{Fence: fence}).Call(ctx, Request{Config: Config{Binary: binary}}) - entered() - done <- err - }() - t.Cleanup(func() { os.WriteFile(finish, nil, 0600); cancel() }) - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(marker); err == nil { - break - } - if time.Now().After(deadline) { - t.Fatal("helper did not start") - } - time.Sleep(time.Millisecond) - } - cancel() - if err := <-done; err == nil { - t.Fatal("caller cancellation hidden") - } - blocked, stop := context.WithTimeout(t.Context(), 30*time.Millisecond) - defer stop() - if unlock, err := fence.Fence(blocked); err == nil { - unlock() - t.Fatal("credential fence forgot a live helper") - } - // A failed exclusive waiter must return its permits immediately. - if release, err := fence.Enter(t.Context()); err != nil { - t.Fatal(err) - } else { - release() - } - if err := os.WriteFile(finish, nil, 0600); err != nil { - t.Fatal(err) - } - final, end := context.WithTimeout(t.Context(), 3*time.Second) - defer end() - release, err := fence.Fence(final) - if err != nil { - t.Fatal("late helper exit did not release fence", err) - } - release() - if release, err := fence.Enter(final); err != nil { - t.Fatal(err) - } else { - release() - } -} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go deleted file mode 100644 index 8d50db340..000000000 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ /dev/null @@ -1,328 +0,0 @@ -// Package e2b adapts the official SDK helper to managed compute operations. -package e2b - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - "unicode" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -const ProtocolVersion = 1 -const SDKVersion = "2.51.0" -const MaxOutputBytes = 1024 * 1024 -const MaxRequestBytes = 72 * 1024 * 1024 -const MaxResponseBytes = 16 * 1024 * 1024 - -// Config contains trusted deployment configuration; APIKey travels only on stdin. -type Config struct { - Binary, StateDir, InstallationID, APIKey, Template, APIURL, Domain string - TimeoutSeconds int - Resources *sandbox.Resources -} - -type Request struct { - Version int - Operation string - Config Config - Reference sandbox.Reference - // References lists the allocations of one read-only observe request. - References []sandbox.Reference `json:",omitempty"` - Bootstrap *sandbox.Bootstrap `json:",omitempty"` - RuntimeBootstrap *runtimebootstrap.Connection `json:",omitempty"` - Command *sandbox.Command `json:",omitempty"` - Deadline time.Time -} -type Response struct { - Version int - Info *sandbox.Info `json:",omitempty"` - Command *sandbox.CommandResult `json:",omitempty"` - ErrorCode string - DeploymentValid bool `json:",omitempty"` - TemplateBuild *TemplateBuild `json:",omitempty"` - Templates []TemplateSummary `json:",omitempty"` - Builds []ReadyBuild `json:",omitempty"` - Observations []Observation `json:",omitempty"` -} - -type TemplateSummary struct { - ID string `json:"id"` - Names []string `json:"names"` -} -type ReadyBuild struct { - ID string `json:"id"` - CPUs uint32 `json:"cpus"` - MemoryMiB uint32 `json:"memory_mib"` -} - -// Discover uses the same pinned SDK helper without requiring a saved deployment. -// Its credential is passed only to the helper on stdin. -func Discover(ctx context.Context, caller Caller, binary, apiKey, apiURL, domain, template string) (Response, error) { - if caller == nil || !filepath.IsAbs(binary) || apiKey == "" || len(apiKey) > 4096 || - strings.ContainsFunc(apiKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) { - return Response{}, sandbox.ErrInvalid - } - if _, _, err := NormalizeEndpoint(apiURL, domain); err != nil { - return Response{}, sandbox.ErrInvalid - } - operation := "list_templates" - if template != "" { - if len(template) > 128 { - return Response{}, sandbox.ErrInvalid - } - for _, r := range template { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { - return Response{}, sandbox.ErrInvalid - } - } - operation = "list_builds" - } - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - deadline, _ := ctx.Deadline() - out, err := caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: Config{Binary: binary, APIKey: apiKey, APIURL: apiURL, Domain: domain, Template: template}, Deadline: deadline}) - if err != nil || out.Version != ProtocolVersion { - return Response{}, sandbox.ErrComputeUnconfirmed - } - if out.ErrorCode == "invalid" { - return Response{}, sandbox.ErrInvalid - } - if out.ErrorCode != "" || out.Info != nil || out.Command != nil || out.TemplateBuild != nil || out.Observations != nil { - return Response{}, sandbox.ErrComputeUnconfirmed - } - if operation == "list_templates" { - if out.Templates == nil || out.Builds != nil || len(out.Templates) > 200 { - return Response{}, sandbox.ErrComputeUnconfirmed - } - for _, item := range out.Templates { - if item.ID == "" || len(item.ID) > 128 || len(item.Names) > 20 { - return Response{}, sandbox.ErrComputeUnconfirmed - } - for _, r := range item.ID { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { - return Response{}, sandbox.ErrComputeUnconfirmed - } - } - for _, name := range item.Names { - if len(name) > 128 { - return Response{}, sandbox.ErrComputeUnconfirmed - } - } - } - } else { - if out.Builds == nil || out.Templates != nil || len(out.Builds) > 200 { - return Response{}, sandbox.ErrComputeUnconfirmed - } - for _, item := range out.Builds { - if !validID(item.ID) || item.CPUs == 0 || item.MemoryMiB == 0 { - return Response{}, sandbox.ErrComputeUnconfirmed - } - } - } - return out, nil -} - -// TemplateBuild is the fixed build as read by deployment validation. -// RootDiskMiB is nil when E2B does not report the build's disk size. -type TemplateBuild struct { - Status string - CPUs, MemoryMiB uint32 - RootDiskMiB *uint32 -} -type Caller interface { - Call(context.Context, Request) (Response, error) -} -type Provider struct { - config Config - caller Caller - now func() time.Time -} - -var _ sandbox.SandboxProvider = (*Provider)(nil) - -func validID(value string) bool { - id, err := uuid.Parse(value) - return err == nil && id != uuid.Nil && id.String() == value -} -func validReference(r sandbox.Reference) bool { - return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) -} -func (c Config) Validate() error { - if c.Resources != nil && ValidateResources(*c.Resources) != nil { - return sandbox.ErrInvalid - } - if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { - return sandbox.ErrInvalid - } - if err := ValidateConfiguration(&sandbox.E2BConfiguration{APIKey: c.APIKey, Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}); err != nil { - return err - } - for _, path := range []string{c.Binary, c.StateDir} { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return sandbox.ErrInvalid - } - } - binary, err := os.Stat(c.Binary) - if err != nil || !binary.Mode().IsRegular() || binary.Mode().Perm()&0111 == 0 { - return sandbox.ErrInvalid - } - state, err := os.Lstat(c.StateDir) - if err != nil || !state.IsDir() || state.Mode().Perm()&0077 != 0 { - return sandbox.ErrInvalid - } - return nil -} -func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } -func NewWithCaller(c Config, caller Caller) (*Provider, error) { - if c.Validate() != nil || caller == nil { - return nil, sandbox.ErrInvalid - } - if c.Resources != nil { - resources := *c.Resources - c.Resources = &resources - } - return &Provider{config: c, caller: caller, now: time.Now}, nil -} -func (p *Provider) call(ctx context.Context, operation string, r sandbox.Reference, b *sandbox.Bootstrap, command *sandbox.Command) (Response, error) { - deadline, ok := ctx.Deadline() - if !ok || (operation != "validate_deployment" && !validReference(r)) { - return unstarted(operation, r), sandbox.ErrInvalid - } - if err := ctx.Err(); err != nil { - return unstarted(operation, r), err - } - var connection *runtimebootstrap.Connection - if b != nil { - value := b.RuntimeConnection() - if value.Validate() != nil { - return unstarted(operation, r), sandbox.ErrInvalid - } - connection = &value - } - out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, RuntimeBootstrap: connection, Command: command, Deadline: deadline}) - if errors.Is(err, errHelperNotStarted) { - return unstarted(operation, r), sandbox.ErrComputeUnconfirmed - } - if err != nil || out.Version != ProtocolVersion { - if operation == "command" { - return Response{}, sandbox.ErrCommandUnconfirmed - } - return Response{}, sandbox.ErrComputeUnconfirmed - } - if out.Info != nil && (out.Info.Reference != r || len(out.Info.ProviderID) > 256 || len(out.Info.State) > 64 || out.Info.BootstrapComplete && !out.Info.CreateSettled) { - return Response{}, sandbox.ErrComputeUnconfirmed - } - switch out.ErrorCode { - case "": - return out, nil - case "template_invalid": - return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) - case "team_mismatch": - return out, ErrTeamMismatch - case "unauthorized": - return out, ErrCredentialInvalid - case "invalid": - return out, sandbox.ErrInvalid - case "ownership": - return out, sandbox.ErrOwnership - case "exists": - return out, sandbox.ErrExists - case "not_found": - return out, sandbox.ErrNotFound - case "command_unconfirmed": - return out, sandbox.ErrCommandUnconfirmed - default: - return out, sandbox.ErrComputeUnconfirmed - } -} - -// ValidateDeployment verifies team ownership and reads the exact immutable build without creating compute or -// allocation receipts. Candidate configuration remains unpublished until it passes. -// It returns the build as read. Without configured Resources it only requires a -// ready build, whose CPU and memory the caller then adopts as the selection. -func (p *Provider) ValidateDeployment(ctx context.Context) (TemplateBuild, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - out, err := p.call(ctx, "validate_deployment", sandbox.Reference{}, nil, nil) - if err != nil { - return TemplateBuild{}, err - } - build := out.TemplateBuild - if !out.DeploymentValid || out.Info != nil || out.Command != nil || out.Observations != nil || build == nil || build.Status != "ready" || - build.CPUs == 0 || build.MemoryMiB == 0 || build.RootDiskMiB != nil && *build.RootDiskMiB == 0 || - p.config.Resources != nil && (build.CPUs != p.config.Resources.CPUs || build.MemoryMiB != p.config.Resources.MemoryMiB) { - return TemplateBuild{}, sandbox.ErrComputeUnconfirmed - } - return *build, nil -} -func (p *Provider) info(ctx context.Context, operation string, r sandbox.Reference, b *sandbox.Bootstrap) (sandbox.Info, error) { - out, err := p.call(ctx, operation, r, b, nil) - if out.Info != nil { - return *out.Info, err - } - if err == nil { - err = sandbox.ErrComputeUnconfirmed - } - return sandbox.Info{Reference: r}, err -} -func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} - if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || policy.Validate() != nil || b.RuntimeConnection().Validate() != nil { - info := sandbox.Info{Reference: b.Reference} - if validReference(b.Reference) { - info.State, info.CreateSettled = "absent", true - } - return info, sandbox.ErrInvalid - } - b.AllowedDomains = policy.Hosts() - return p.info(ctx, "create", b.Reference, &b) -} -func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - return p.info(ctx, "inspect", r, nil) -} -func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - return p.info(ctx, "renew", r, nil) -} -func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { - out, err := p.call(ctx, "kill", r, nil, nil) - if err == nil && (out.Info == nil || !out.Info.CreateSettled || out.Info.State != "absent") { - return sandbox.ErrComputeUnconfirmed - } - return err -} -func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - if len(c.Args) == 0 || len(c.Stdin) > sandbox.MaxCommandInputBytes || c.Directory != "" && !filepath.IsAbs(c.Directory) { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - for _, arg := range c.Args { - if strings.ContainsRune(arg, 0) { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - } - out, err := p.call(ctx, "command", r, nil, &c) - if err != nil { - return sandbox.CommandResult{}, err - } - if out.Command == nil || len(out.Command.Stdout) > MaxOutputBytes || len(out.Command.Stderr) > MaxOutputBytes { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - return *out.Command, nil -} - -// A fresh allocation Create rejected before process startup has no cloud effects. -// The common Provider contract forbids replaying an earlier unknown Create. -func unstarted(operation string, r sandbox.Reference) Response { - if operation == "create" && validReference(r) { - return Response{Info: &sandbox.Info{Reference: r, State: "absent", CreateSettled: true}} - } - return Response{} -} diff --git a/services/agents-api/internal/sandbox/e2b/provider_test.go b/services/agents-api/internal/sandbox/e2b/provider_test.go deleted file mode 100644 index 8d88aef25..000000000 --- a/services/agents-api/internal/sandbox/e2b/provider_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package e2b - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -type fakeCaller struct { - response Response - err error - requests []Request -} - -func (f *fakeCaller) Call(_ context.Context, q Request) (Response, error) { - f.requests = append(f.requests, q) - return f.response, f.err -} -func fixture(t *testing.T) (*Provider, *fakeCaller, sandbox.Reference) { - t.Helper() - root := t.TempDir() - if err := os.Chmod(root, 0700); err != nil { - t.Fatal(err) - } - binary := filepath.Join(root, "helper") - if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - config := Config{Binary: binary, StateDir: root, InstallationID: uuid.NewString(), APIKey: "private-test-key", Template: "test:" + uuid.NewString(), TimeoutSeconds: 300} - caller := &fakeCaller{response: Response{Version: ProtocolVersion}} - provider, err := NewWithCaller(config, caller) - if err != nil { - t.Fatal(err) - } - return provider, caller, sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} -} -func bounded(t *testing.T) context.Context { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - t.Cleanup(cancel) - return ctx -} -func TestReadOnlyConstructionAndValidation(t *testing.T) { - p, f, _ := fixture(t) - if len(f.requests) != 0 { - t.Fatal("construction invoked helper") - } - for _, change := range []func(*Config){func(c *Config) { c.Template = "mutable" }, func(c *Config) { c.APIKey = "key\n" }, func(c *Config) { c.StateDir = "relative" }, func(c *Config) { c.TimeoutSeconds = 0 }, func(c *Config) { c.Binary = "/missing/helper" }} { - c := p.config - change(&c) - if _, err := NewWithCaller(c, f); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("invalid configuration accepted") - } - } -} -func TestReferenceBoundSettlement(t *testing.T) { - p, f, r := fixture(t) - f.response.Info = &sandbox.Info{Reference: r, State: "absent", CreateSettled: true} - info, err := p.GetInfo(bounded(t), r) - if err != nil || !info.CreateSettled || info.State != "absent" { - t.Fatalf("absence proof: %+v %v", info, err) - } - f.response.Info.AllocationID = uuid.NewString() - if _, err = p.GetInfo(bounded(t), r); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { - t.Fatal("foreign proof accepted", err) - } -} -func TestUnknownDoesNotLeakHelperDiagnosticOrProveAbsence(t *testing.T) { - p, f, r := fixture(t) - f.err = errors.New("private-account-key in SDK response") - info, err := p.GetInfo(bounded(t), r) - if !errors.Is(err, sandbox.ErrComputeUnconfirmed) || info.CreateSettled { - t.Fatal(info, err) - } - if err.Error() != "sandbox lifecycle outcome unconfirmed" { - t.Fatal("helper diagnostic leaked") - } - f.err = nil - f.response.ErrorCode = "not_found" - if _, err = p.GetInfo(bounded(t), r); !errors.Is(err, sandbox.ErrNotFound) { - t.Fatal(err) - } -} -func TestKillRequiresTerminalProof(t *testing.T) { - p, f, r := fixture(t) - for _, info := range []*sandbox.Info{nil, {Reference: r, State: "absent"}, {Reference: r, State: "running", CreateSettled: true}} { - f.response.Info = info - if !errors.Is(p.Kill(bounded(t), r), sandbox.ErrComputeUnconfirmed) { - t.Fatal("unproven cleanup accepted") - } - } - f.response.Info = &sandbox.Info{Reference: r, State: "absent", CreateSettled: true} - if err := p.Kill(bounded(t), r); err != nil { - t.Fatal(err) - } -} -func TestCreateAndCommandUseOnlyPrivateRequest(t *testing.T) { - p, f, r := fixture(t) - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "private-bootstrap", NetworkAccess: "enabled"} - f.response.Info = &sandbox.Info{Reference: r, State: "running", ProviderID: "native-id", CreateSettled: true, BootstrapComplete: true} - if _, err := p.Create(bounded(t), b); err != nil { - t.Fatal(err) - } - q := f.requests[0] - if q.Operation != "create" || q.Bootstrap == nil || q.Bootstrap.Credential != b.Credential || q.Config.APIKey != p.config.APIKey { - t.Fatal("private request lost") - } - f.response.Info = nil - f.response.Command = &sandbox.CommandResult{ExitCode: 7, Stdout: "output"} - got, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"cat"}, Stdin: []byte("private input")}) - if err != nil || got.ExitCode != 7 { - t.Fatal(got, err) - } - f.err = context.DeadlineExceeded - if _, err = p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"true"}}); !errors.Is(err, sandbox.ErrCommandUnconfirmed) { - t.Fatal(err) - } - if len(f.requests) != 3 { - t.Fatal("operation was replayed") - } -} -func TestDeadlineAndCommandAdmission(t *testing.T) { - p, f, r := fixture(t) - if _, err := p.GetInfo(context.Background(), r); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal(err) - } - for _, cmd := range []sandbox.Command{{}, {Args: []string{"x\x00"}}, {Args: []string{"cat"}, Directory: "relative"}} { - if _, err := p.RunCommand(bounded(t), r, cmd); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal(err) - } - } - if len(f.requests) != 0 { - t.Fatal("invalid operation reached helper") - } -} - -func TestDefinitePreHelperCreateFailureCarriesAbsenceProof(t *testing.T) { - p, f, r := fixture(t) - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "private", NetworkAccess: "enabled"} - for _, err := range []error{errHelperNotStarted, context.DeadlineExceeded} { - f.err = err - info, gotErr := p.Create(bounded(t), b) - if gotErr == nil { - t.Fatal("failure lost") - } - if errors.Is(err, errHelperNotStarted) { - if !info.CreateSettled || info.State != "absent" { - t.Fatal("missing definite absence proof") - } - } else if info.CreateSettled { - t.Fatal("timeout proved absence") - } - } -} - -func TestInvalidTemplateHasSafeActionableDiagnostic(t *testing.T) { - p, f, r := fixture(t) - f.response.ErrorCode = "template_invalid" - f.response.Info = &sandbox.Info{Reference: r, ProviderID: "owned", State: "running", CreateSettled: true} - info, err := p.GetInfo(bounded(t), r) - if !errors.Is(err, sandbox.ErrInvalid) || !strings.Contains(err.Error(), "Build a template with this release's build-template.py") || !info.CreateSettled { - t.Fatalf("invalid template: %+v %v", info, err) - } -} diff --git a/services/agents-api/internal/sandbox/microsandbox/contract_test.go b/services/agents-api/internal/sandbox/microsandbox/contract_test.go deleted file mode 100644 index 3a11cb15d..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/contract_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package microsandbox - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" - "github.com/google/uuid" - "testing" -) - -func TestProviderContract(t *testing.T) { - contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { - c, r := testConfig(), testRef() - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} - var calls []string - p, err := NewWithCaller(c, callerFunc(func(ctx context.Context, q Request) (Response, error) { - calls = append(calls, q.Operation) - if q.Reference != r { - t.Fatal("native request lost allocation identity") - } - switch s.Fault { - case contracttest.Canceled: - cancel() - return Response{}, ctx.Err() - case contracttest.ForeignOwnership: - if s.Operation == "kill" { - return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil - } - foreign := r - foreign.AllocationID = uuid.NewString() - return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, foreign, 0), ID: "foreign"}, Status: "running", BootstrapComplete: true}}, nil - case contracttest.CleanupFailure: - return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil - default: - return Response{}, errors.New("lost helper response") - } - })) - if err != nil { - t.Fatal(err) - } - nativeOperation := s.Operation - if nativeOperation == "renew" { - nativeOperation = "inspect" - } - return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{nativeOperation}} - }) -} - -func TestProviderContractObservation(t *testing.T) { - c, r := testConfig(), testRef() - p, err := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { - if q.Operation != "inspect" && q.Operation != "create" { - t.Fatal("observation mutated compute") - } - status := "stopped" - if q.Operation == "create" { - status = "running" - } - return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, r, 0), ID: "native-owned"}, Status: status}}, nil - })) - if err != nil { - t.Fatal(err) - } - b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} - got, err := p.Create(deadline(t), b) - contracttest.AssertObservation(t, got, err, r, "native-owned", "running") - got, err = p.GetInfo(deadline(t), r) - contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") - got, err = p.Renew(deadline(t), r) - contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") -} diff --git a/services/agents-api/internal/sandbox/microsandbox/creation_settlement_test.go b/services/agents-api/internal/sandbox/microsandbox/creation_settlement_test.go deleted file mode 100644 index a80730fc8..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/creation_settlement_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package microsandbox - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func TestCreateConfigurationRejectionSettlement(t *testing.T) { - config, ref := testConfig(), testRef() - bootstrap := sandbox.Bootstrap{Reference: ref, SessionID: ref.TenantID, DeviceID: ref.EnvironmentID, - CoreURL: "https://core.example/api/v1", Credential: "fixture", NetworkAccess: "disabled"} - for _, test := range []struct { - name string - change func(*Response) - failure error - settled bool - }{ - {name: "exact initial compute", settled: true}, - {name: "no proof", change: func(r *Response) { r.CreateSettled = false }}, - {name: "no state", change: func(r *Response) { r.State = nil }}, - {name: "no native identity", change: func(r *Response) { r.State.Compute.ID = "" }}, - {name: "foreign allocation", change: func(r *Response) { r.State.Compute.Name = "foreign" }}, - {name: "foreign generation", change: func(r *Response) { r.State.Compute.Generation = 1 }}, - {name: "restored compute", change: func(r *Response) { value := testSnapshot(); r.State.Compute.RestoredFrom = &value }}, - {name: "bootstrap already complete", change: func(r *Response) { r.State.BootstrapComplete = true }}, - {name: "absence is not proof", change: func(r *Response) { r.State.Status = "absent" }}, - {name: "missing status", change: func(r *Response) { r.State.Status = "" }}, - {name: "wrong protocol", change: func(r *Response) { r.Version++ }}, - {name: "ownership rejection", change: func(r *Response) { r.ErrorCode = "ownership" }}, - {name: "unknown create", change: func(r *Response) { r.ErrorCode = "unconfirmed" }}, - {name: "lost response", failure: context.DeadlineExceeded}, - } { - t.Run(test.name, func(t *testing.T) { - response := Response{Version: ProtocolVersion, CreateSettled: true, ErrorCode: "invalid", - State: &State{Compute: Compute{Name: Name(config, ref, 0), ID: "local:created"}, Status: "running"}} - if test.change != nil { - test.change(&response) - } - calls := 0 - provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { - calls++ - if request.Operation != "create" || request.Reference != ref { - t.Fatal("changed creation request") - } - return response, test.failure - })) - if err != nil { - t.Fatal(err) - } - info, err := provider.Create(deadline(t), bootstrap) - if err == nil || info.CreateSettled != test.settled || info.BootstrapComplete || calls != 1 { - t.Fatal("rejection changed readiness or settlement", info, err, calls) - } - if test.settled && (!errors.Is(err, sandbox.ErrInvalid) || info.Reference != ref || info.ProviderID != "local:created" || info.State == "absent") { - t.Fatal("settlement lost the original rejection or compute identity", info, err) - } - }) - } -} - -func TestNonCreateResponseCannotSettleCreation(t *testing.T) { - for _, code := range []string{"", "invalid", "ownership", "unconfirmed"} { - t.Run(code, func(t *testing.T) { - config, ref := testConfig(), testRef() - provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, q Request) (Response, error) { - return Response{Version: ProtocolVersion, ErrorCode: code, CreateSettled: true, - State: &State{Compute: Compute{Name: Name(config, ref, 0), ID: "local:observed"}, Status: "running"}}, nil - })) - if err != nil { - t.Fatal(err) - } - for _, read := range []func(context.Context, sandbox.Reference) (sandbox.Info, error){provider.GetInfo, provider.Renew} { - info, err := read(deadline(t), ref) - if (err == nil) != (code == "") || info.CreateSettled { - t.Fatal("ordinary inspection acquired creation settlement", info, err) - } - } - }) - } -} diff --git a/services/agents-api/internal/sandbox/microsandbox/deployment.go b/services/agents-api/internal/sandbox/microsandbox/deployment.go deleted file mode 100644 index 6b9da5311..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/deployment.go +++ /dev/null @@ -1,12 +0,0 @@ -package microsandbox - -import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: true, Runtime: true} } - -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - return s.ValidatePolicy("microsandbox", Policy()) -} diff --git a/services/agents-api/internal/sandbox/microsandbox/identity.go b/services/agents-api/internal/sandbox/microsandbox/identity.go deleted file mode 100644 index 4fe7c6f2e..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/identity.go +++ /dev/null @@ -1,136 +0,0 @@ -package microsandbox - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "path/filepath" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func validID(s string) bool { - v, e := uuid.Parse(s) - return e == nil && v != uuid.Nil && v.String() == s -} -func ValidReference(r sandbox.Reference) bool { - return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) -} -func validHash(s string) bool { - b, e := hex.DecodeString(s) - return e == nil && len(b) == 32 && strings.ToLower(s) == s -} -func (c Config) Validate() error { - if !validID(c.InstallationID) || !filepath.IsAbs(c.HelperPath) || !filepath.IsAbs(c.RuntimeHome) || !filepath.IsAbs(c.RuntimePath) || !filepath.IsAbs(c.FirmwarePath) || !validHash(c.RuntimeSHA256) || !validHash(c.FirmwareSHA256) || c.MemoryMiB == 0 || c.CPUs == 0 || c.RootDiskMiB == 0 || c.EnvironmentDiskMiB == 0 { - return sandbox.ErrInvalid - } - imageName, digest, pinned := strings.Cut(c.Image, "@sha256:") - if !pinned || imageName == "" || strings.Contains(imageName, "@") || !validHash(digest) { - return sandbox.ErrInvalid - } - if c.Network.DefaultEgress != "allow" && c.Network.DefaultEgress != "deny" { - return sandbox.ErrInvalid - } - if c.Network.DefaultIngress != "allow" && c.Network.DefaultIngress != "deny" { - return sandbox.ErrInvalid - } - for _, r := range c.Network.Rules { - if (r.Action != "allow" && r.Action != "deny") || (r.Direction != "egress" && r.Direction != "ingress") || r.Destination == "" { - return sandbox.ErrInvalid - } - } - return nil -} -func Name(c Config, r sandbox.Reference, g uint64) string { - h := sha256.Sum256([]byte(c.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) - return fmt.Sprintf("oac-%x-g%d", h[:16], g) -} -func SnapshotReference(c Config, r sandbox.Reference, operation string) string { - return Name(c, r, 0) + ":s-" + operation -} -func Labels(c Config, r sandbox.Reference) map[string]string { - return map[string]string{"io.oac.installation": c.InstallationID, "io.oac.tenant": r.TenantID, "io.oac.environment": r.EnvironmentID, "io.oac.allocation": r.AllocationID} -} -func ValidateCompute(c Config, r sandbox.Reference, v Compute) error { - if !ValidReference(r) || v.Name != Name(c, r, v.Generation) { - return sandbox.ErrInvalid - } - if v.Generation == 0 { - if v.RestoredFrom != nil { - return sandbox.ErrInvalid - } - } else { - if v.RestoredFrom == nil || ValidateSnapshot(c, r, *v.RestoredFrom) != nil || v.RestoredFrom.SourceGeneration >= v.Generation { - return sandbox.ErrInvalid - } - } - return nil -} -func ValidateSnapshot(c Config, r sandbox.Reference, s SnapshotIdentity) error { - if !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || s.Digest == "" || s.CheckpointID == "" || s.CheckpointRoot == "" { - return sandbox.ErrInvalid - } - return nil -} -func ValidateBootstrap(b sandbox.Bootstrap) error { - if !ValidReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { - return sandbox.ErrInvalid - } - return (agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains}).Validate() -} -func ValidateCommand(c sandbox.Command) error { - if len(c.Args) == 0 || c.Args[0] == "" || len(c.Stdin) > sandbox.MaxCommandInputBytes || (c.Directory != "" && !filepath.IsAbs(c.Directory)) { - return sandbox.ErrInvalid - } - for _, a := range c.Args { - if strings.IndexByte(a, 0) >= 0 { - return sandbox.ErrInvalid - } - } - return nil -} -func ValidateRequest(q Request) error { - if q.Version != ProtocolVersion || q.Config.Validate() != nil || !ValidReference(q.Reference) || q.Deadline.IsZero() { - return sandbox.ErrInvalid - } - switch q.Operation { - case "create": - if q.Bootstrap == nil || q.Bootstrap.Reference != q.Reference || ValidateBootstrap(*q.Bootstrap) != nil { - return sandbox.ErrInvalid - } - case "inspect", "kill", "resume_compute", "metrics": - if q.Operation == "resume_compute" && q.Compute.ID == "" { - return sandbox.ErrInvalid - } - return ValidateCompute(q.Config, q.Reference, q.Compute) - case "command": - if q.Command == nil || ValidateCommand(*q.Command) != nil { - return sandbox.ErrInvalid - } - return ValidateCompute(q.Config, q.Reference, q.Compute) - case "suspend": - s := q.Suspend - if s == nil || s.Reference != q.Reference || !validID(s.OperationID) || s.Source.ID == "" || ValidateCompute(q.Config, q.Reference, s.Source) != nil { - return sandbox.ErrInvalid - } - if s.Snapshot != nil && (ValidateSnapshot(q.Config, q.Reference, *s.Snapshot) != nil || s.Snapshot.OperationID != s.OperationID || s.Snapshot.SourceID != s.Source.ID || s.Snapshot.SourceName != s.Source.Name || s.Snapshot.SourceGeneration != s.Source.Generation) { - return sandbox.ErrInvalid - } - case "resume": - v := q.Resume - if v == nil || v.Reference != q.Reference || !validID(v.OperationID) || ValidateSnapshot(q.Config, q.Reference, v.Snapshot) != nil || ValidateCompute(q.Config, q.Reference, v.Target) != nil || v.Target.RestoredFrom == nil || *v.Target.RestoredFrom != v.Snapshot { - return sandbox.ErrInvalid - } - case "delete_snapshot": - if q.Snapshot == nil { - return sandbox.ErrInvalid - } - return ValidateSnapshot(q.Config, q.Reference, *q.Snapshot) - default: - return sandbox.ErrInvalid - } - return nil -} diff --git a/services/agents-api/internal/sandbox/microsandbox/operations.go b/services/agents-api/internal/sandbox/microsandbox/operations.go deleted file mode 100644 index cd0014448..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/operations.go +++ /dev/null @@ -1,47 +0,0 @@ -package microsandbox - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var _ sandbox.CheckpointProvider = (*Provider)(nil) -var _ sandbox.SelectionDiscoverer = (*Provider)(nil) -var _ sandbox.CredentialVerifier = (*Provider)(nil) -var _ runtimeobs.BatchSource = (*Provider)(nil) - -// Operations is this adapter's complete authored resource contract. -func Operations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Supported}, - "NewCompute": {State: providercontract.Supported}, - "GetCompute": {State: providercontract.Supported}, - "Suspend": {State: providercontract.Supported}, - "Resume": {State: providercontract.Supported}, - "KillCompute": {State: providercontract.Supported}, - "DeleteSnapshot": {State: providercontract.Supported}, - "RunCommandCompute": {State: providercontract.Supported}, - "ResumeCompute": {State: providercontract.Supported}, - "Observe": {State: providercontract.Supported}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_batch_observation"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_selection_discovery"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_credential_verification"}, - } -} -func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } -func (p *Provider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: Operations()["ObserveBatch"].Reason} -} -func (p *Provider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { - return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: Operations()["DiscoverSelection"].Reason} -} -func (p *Provider) VerifyCredential(context.Context, []sandbox.Reference) error { - return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: Operations()["VerifyCredential"].Reason} -} diff --git a/services/agents-api/internal/sandbox/microsandbox/process.go b/services/agents-api/internal/sandbox/microsandbox/process.go deleted file mode 100644 index 7600b386c..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/process.go +++ /dev/null @@ -1,233 +0,0 @@ -package microsandbox - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "sync/atomic" - "syscall" -) - -// ProcessCaller never kills a mutating helper on a Core response timeout. -// The helper retains its allocation flock until the SDK mutation settles, -// including after Core exits. Output is still drained and bounded by this waiter. -type ProcessCaller struct { - active atomic.Int64 - // LeasePath belongs to the permanent node state namespace, not a release. - // It is never unlinked, including after the generation is collected. - LeasePath string - LeaseIdentity LeaseIdentity -} - -func (p *ProcessCaller) Quiescent() bool { return p.active.Load() == 0 } - -func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { - data, e := json.Marshal(q) - if e != nil || len(data) > MaxRequestBytes { - return Response{}, errors.New("invalid helper request") - } - lease, err := p.acquireLease() - if err != nil { - return Response{}, errors.New("generation helper lease unavailable") - } - releaseLease := func() { - if lease != nil { - _ = lease.Close() - } - } - cmd := exec.Command(q.Config.HelperPath) - cmd.Stdin = bytes.NewReader(data) - cmd.Env = HelperEnvironment(os.Environ(), q.Config) - if lease != nil { - cmd.ExtraFiles = []*os.File{lease} - cmd.Env = append(cmd.Env, "OAC_NODE_GENERATION_LEASE_FD=3") - } - stdout := &limitBuffer{limit: MaxResponseBytes} - stderr := &limitBuffer{limit: MaxOutputBytes} - cmd.Stdout, cmd.Stderr = stdout, stderr - p.active.Add(1) - if e := cmd.Start(); e != nil { - p.active.Add(-1) - releaseLease() - return Response{}, errors.New("helper unavailable") - } - done := make(chan error, 1) - go func() { err := cmd.Wait(); releaseLease(); p.active.Add(-1); done <- err }() - select { - case <-ctx.Done(): - return Response{}, ctx.Err() - case err := <-done: - if err != nil || stdout.exceeded || stderr.exceeded { - return Response{}, errors.New("helper result unconfirmed") - } - } - var out Response - decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) - decoder.DisallowUnknownFields() - if e := decoder.Decode(&out); e != nil { - return Response{}, errors.New("invalid helper response") - } - var extra any - if e := decoder.Decode(&extra); e != io.EOF { - return Response{}, errors.New("trailing helper response") - } - return out, nil -} - -// Strip native selector and credential-bearing inherited configuration. Operator -// proxy settings are not passed into guest environments by this adapter. -func HelperEnvironment(env []string, c Config) []string { - out := []string{} - for _, v := range env { - key, _, _ := strings.Cut(v, "=") - if key == "HOME" || key == "PATH" || key == "TMPDIR" || key == "LANG" || key == "SSL_CERT_FILE" || key == "SSL_CERT_DIR" { - out = append(out, v) - } - } - return append(out, "MSB_HOME="+c.RuntimeHome, "MSB_PATH="+c.RuntimePath, "MSB_LIBKRUNFW_PATH="+c.FirmwarePath, "MSB_BACKEND=local", "RUST_LOG=off", "NO_COLOR=1") -} - -type limitBuffer struct { - bytes.Buffer - limit int - exceeded bool -} - -func (b *limitBuffer) Write(v []byte) (int, error) { - n := len(v) - left := b.limit - b.Len() - if n > left { - b.exceeded = true - v = v[:left] - } - _, _ = b.Buffer.Write(v) - return n, nil // Drain excess output without unbounded retention or pipe deadlock. -} - -// A shared open-file-description flock survives node exit through the inherited -// helper descriptor. Close only our descriptor; LOCK_UN would also unlock the -// helper's copy. Collection holds the exclusive lock before touching any bytes. -func (p *ProcessCaller) acquireLease() (*os.File, error) { - if p.LeasePath == "" { - return nil, nil - } - fd, err := syscall.Open(p.LeasePath, syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0600) - if err != nil { - return nil, err - } - file := os.NewFile(uintptr(fd), p.LeasePath) - fail := func(err error) (*os.File, error) { _ = file.Close(); return nil, err } - info, err := file.Stat() - if err != nil { - return fail(err) - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || stat.Uid != uint32(os.Geteuid()) || stat.Nlink != 1 { - return fail(errors.New("invalid generation lease")) - } - if err := syscall.Flock(fd, syscall.LOCK_SH|syscall.LOCK_NB); err != nil { - return fail(err) - } - named, err := os.Lstat(p.LeasePath) - if err != nil || !os.SameFile(info, named) { - return fail(errors.New("generation lease replaced")) - } - if err := p.verifyLeaseIdentity(stat); err != nil { - return fail(err) - } - for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { - if _, err := os.Lstat(strings.TrimSuffix(p.LeasePath, ".lease") + suffix); !os.IsNotExist(err) { - return fail(errors.New("generation is not finalized and usable")) - } - } - if err := p.verifyFinalizedGeneration(); err != nil { - return fail(err) - } - return file, nil -} - -// LeaseIdentity is recorded by the installer before a generation can spawn a -// helper. Neither opener adopts a missing record or a replacement lease inode. -type LeaseIdentity struct { - InstallationID string `json:"installation_id"` - Generation uint64 `json:"generation"` - SpecificationDigest string `json:"specification_digest"` -} - -func (p *ProcessCaller) verifyLeaseIdentity(lease *syscall.Stat_t) error { - path := strings.TrimSuffix(p.LeasePath, ".lease") + ".lease-identity" - fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) - if err != nil { - return err - } - file := os.NewFile(uintptr(fd), path) - defer file.Close() - info, err := file.Stat() - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Geteuid()) || st.Nlink != 1 || st.Size > 4096 { - return errors.New("invalid durable generation lease identity") - } - var saved struct { - LeaseIdentity - Device uint64 `json:"device"` - Inode uint64 `json:"inode"` - } - decoder := json.NewDecoder(io.LimitReader(file, 4097)) - decoder.DisallowUnknownFields() - if decoder.Decode(&saved) != nil || decoder.Decode(new(any)) != io.EOF || saved.LeaseIdentity != p.LeaseIdentity || saved.Generation == 0 || saved.InstallationID == "" || len(saved.SpecificationDigest) != 64 || saved.Device != uint64(lease.Dev) || saved.Inode != lease.Ino { - return errors.New("generation lease identity differs") - } - named, err := os.Lstat(path) - if err != nil || !os.SameFile(info, named) { - return errors.New("generation lease identity replaced") - } - return nil -} - -// A durable lease alone is not a published provider. Initial enrollment stores -// its final config at provider.json; later generations use .json. -func (p *ProcessCaller) verifyFinalizedGeneration() error { - path := strings.TrimSuffix(p.LeasePath, ".lease") + ".json" - if _, err := os.Lstat(path); os.IsNotExist(err) { - path = filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(p.LeasePath)))), "provider.json") - } - fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) - if err != nil { - return err - } - file := os.NewFile(uintptr(fd), path) - defer file.Close() - info, err := file.Stat() - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Geteuid()) || st.Nlink != 1 || st.Size > 16384 { - return errors.New("invalid finalized generation") - } - var value struct { - InstallationID string `json:"installation_id"` - Generation uint64 `json:"generation"` - Provider string `json:"provider"` - Specification sandbox.DeploymentSpec `json:"specification"` - } - decoder := json.NewDecoder(io.LimitReader(file, 16385)) - if decoder.Decode(&value) != nil || decoder.Decode(new(any)) != io.EOF || value.InstallationID != p.LeaseIdentity.InstallationID || value.Generation != p.LeaseIdentity.Generation || value.Provider != "microsandbox" || value.Specification.Digest(value.Provider) != p.LeaseIdentity.SpecificationDigest { - return errors.New("finalized generation identity differs") - } - named, err := os.Lstat(path) - if err != nil || !os.SameFile(info, named) { - return errors.New("finalized generation was replaced") - } - return nil -} diff --git a/services/agents-api/internal/sandbox/microsandbox/process_test.go b/services/agents-api/internal/sandbox/microsandbox/process_test.go deleted file mode 100644 index 6d60cda93..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/process_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package microsandbox - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -func TestMain(m *testing.M) { - if filepath.Base(os.Args[0]) == "microsandbox-test-agent" { - var q Request - if json.NewDecoder(os.Stdin).Decode(&q) != nil { - os.Exit(2) - } - _, _ = (&ProcessCaller{LeasePath: q.Config.RuntimeHome, LeaseIdentity: testLeaseIdentity()}).Call(context.Background(), q) - os.Exit(0) - } - if filepath.Base(os.Args[0]) == "microsandbox-test-helper" { - var q Request - if json.NewDecoder(os.Stdin).Decode(&q) != nil { - os.Exit(2) - } - switch q.Operation { - case "lease-held": - // Model the new native helper's entrypoint before any SDK subprocess. - if os.Getenv("OAC_NODE_GENERATION_LEASE_FD") != "3" { - os.Exit(4) - } - syscall.CloseOnExec(3) - if os.WriteFile(q.Config.RuntimePath, []byte("started"), 0600) != nil { - os.Exit(3) - } - until := time.Now().Add(15 * time.Second) - for { - if _, err := os.Stat(q.Config.RuntimePath + ".release"); err == nil { - break - } - if time.Now().After(until) { - os.Exit(5) - } - time.Sleep(time.Millisecond) - } - - case "delayed": - time.Sleep(150 * time.Millisecond) - if os.WriteFile(q.Config.RuntimePath, []byte("settled"), 0600) != nil { - os.Exit(3) - } - case "oversize": - fmt.Print(strings.Repeat("x", MaxResponseBytes+1)) - os.Exit(0) - case "trailing": - fmt.Print("{\"Version\":1}{}") - os.Exit(0) - } - _ = json.NewEncoder(os.Stdout).Encode(Response{Version: ProtocolVersion}) - os.Exit(0) - } - os.Exit(m.Run()) -} -func processConfig(t *testing.T) Config { - t.Helper() - c := testConfig() - directory := t.TempDir() - executable, e := os.Executable() - if e != nil { - t.Fatal(e) - } - c.HelperPath = filepath.Join(directory, "microsandbox-test-helper") - if e = os.Symlink(executable, c.HelperPath); e != nil { - t.Fatal(e) - } - c.RuntimePath = filepath.Join(directory, "settled") - return c -} -func TestResponseTimeoutDoesNotKillLifecycleOwner(t *testing.T) { - c := processConfig(t) - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) - defer cancel() - _, e := (&ProcessCaller{}).Call(ctx, Request{Operation: "delayed", Config: c}) - if !errors.Is(e, context.DeadlineExceeded) { - t.Fatalf("want timeout: %v", e) - } - until := time.Now().Add(3 * time.Second) - for time.Now().Before(until) { - if data, e := os.ReadFile(c.RuntimePath); e == nil && string(data) == "settled" { - return - } - time.Sleep(20 * time.Millisecond) - } - t.Fatal("helper was killed before settlement") -} -func TestInvalidOrUnboundedHelperOutputIsUnconfirmed(t *testing.T) { - for _, mode := range []string{"oversize", "trailing"} { - t.Run(mode, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _, e := (&ProcessCaller{}).Call(ctx, Request{Operation: mode, Config: processConfig(t)}) - if e == nil { - t.Fatal("invalid helper output accepted") - } - }) - } -} - -func TestGenerationLeaseSurvivesNodeProcessExit(t *testing.T) { - c := processConfig(t) - c.RuntimeHome = filepath.Join(filepath.Dir(c.RuntimePath), "1.lease") - writeTestLease(t, c.RuntimeHome) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - agent := filepath.Join(filepath.Dir(c.RuntimePath), "microsandbox-test-agent") - if err := os.Symlink(executable, agent); err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(Request{Operation: "lease-held", Config: c}) - command := exec.Command(agent) - command.Stdin = strings.NewReader(string(raw)) - if err := command.Start(); err != nil { - t.Fatal(err) - } - defer func() { _ = command.Process.Kill(); _ = command.Wait() }() - defer func() { - _ = os.WriteFile(c.RuntimePath+".release", nil, 0600) - waitProcessCondition(t, func() bool { return exclusiveLease(c.RuntimeHome) == nil }) - }() - waitProcessCondition(t, func() bool { _, err := os.Stat(c.RuntimePath); return err == nil }) - before, err := os.Stat(c.RuntimeHome) - if err != nil { - t.Fatal(err) - } - if err := command.Process.Kill(); err != nil { - t.Fatal(err) - } - _ = command.Wait() - // A new node has no in-memory references, but cannot collect the old helper. - restarted := &ProcessCaller{LeasePath: c.RuntimeHome, LeaseIdentity: testLeaseIdentity()} - if !restarted.Quiescent() { - t.Fatal("fresh process unexpectedly has references") - } - if err := exclusiveLease(c.RuntimeHome); !errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatalf("live orphan helper lost lease: %v", err) - } - // A fresh node must also refuse an owned replacement regular inode while - // the actual orphan helper still holds the original open file description. - if err := os.Rename(c.RuntimeHome, c.RuntimeHome+".old"); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(c.RuntimeHome, nil, 0600); err != nil { - t.Fatal(err) - } - if file, err := restarted.acquireLease(); err == nil { - file.Close() - t.Fatal("live helper bypassed through replacement inode") - } - if err := exclusiveLease(c.RuntimeHome + ".old"); !errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatal("original helper lock lost", err) - } - if err := os.Remove(c.RuntimeHome); err != nil { - t.Fatal(err) - } - if err := os.Rename(c.RuntimeHome+".old", c.RuntimeHome); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(c.RuntimePath+".release", nil, 0600); err != nil { - t.Fatal(err) - } - waitProcessCondition(t, func() bool { return exclusiveLease(c.RuntimeHome) == nil }) - after, err := os.Stat(c.RuntimeHome) - if err != nil || !os.SameFile(before, after) { - t.Fatal("lease inode changed", err) - } -} - -func exclusiveLease(path string) error { - file, err := os.OpenFile(path, os.O_RDWR, 0600) - if err != nil { - return err - } - defer file.Close() - return syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) -} - -func waitProcessCondition(t *testing.T, condition func() bool) { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if condition() { - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("process condition did not settle") -} - -func TestGenerationLeaseExcludesCollectionAndDroppedGeneration(t *testing.T) { - c := processConfig(t) - lease := filepath.Join(filepath.Dir(c.RuntimePath), "1.lease") - writeTestLease(t, lease) - file, err := os.OpenFile(lease, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - t.Fatal(err) - } - defer file.Close() - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - t.Fatal(err) - } - caller := &ProcessCaller{LeasePath: lease, LeaseIdentity: testLeaseIdentity()} - if _, err := caller.Call(t.Context(), Request{Operation: "delayed", Config: c}); err == nil { - t.Fatal("helper started during exclusive collection") - } - if err := os.WriteFile(strings.TrimSuffix(lease, ".lease")+".dropped", []byte("{}"), 0600); err != nil { - t.Fatal(err) - } - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil { - t.Fatal(err) - } - if _, err := caller.Call(t.Context(), Request{Operation: "delayed", Config: c}); err == nil { - t.Fatal("helper started after payload collection") - } - if _, err := os.Stat(c.RuntimePath); !os.IsNotExist(err) { - t.Fatal("refused helper mutated files", err) - } - // Never accept an alternate inode through symlinks or multiply linked files. - if err := os.Remove(lease); err != nil { - t.Fatal(err) - } - if err := os.Symlink(c.RuntimePath, lease); err != nil { - t.Fatal(err) - } - if _, err := caller.acquireLease(); err == nil { - t.Fatal("symlink lease accepted") - } -} - -func testLeaseIdentity() LeaseIdentity { - return LeaseIdentity{InstallationID: "test-installation", Generation: 1, SpecificationDigest: (sandbox.DeploymentSpec{}).Digest("microsandbox")} -} - -func writeTestLease(t *testing.T, path string) { - t.Helper() - if err := os.WriteFile(path, nil, 0600); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - st := info.Sys().(*syscall.Stat_t) - value := struct { - LeaseIdentity - Device uint64 `json:"device"` - Inode uint64 `json:"inode"` - }{testLeaseIdentity(), uint64(st.Dev), st.Ino} - raw, _ := json.Marshal(value) - if err := os.WriteFile(strings.TrimSuffix(path, ".lease")+".lease-identity", raw, 0600); err != nil { - t.Fatal(err) - } - writeTestFinalGeneration(t, path) -} - -func writeTestFinalGeneration(t *testing.T, path string) { - t.Helper() - raw, _ := json.Marshal(map[string]any{"installation_id": "test-installation", "generation": 1, "provider": "microsandbox", "specification": sandbox.DeploymentSpec{}}) - if err := os.WriteFile(strings.TrimSuffix(path, ".lease")+".json", raw, 0600); err != nil { - t.Fatal(err) - } -} - -func TestGenerationLeaseReplacementRejectedAfterRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "1.lease") - writeTestLease(t, path) - caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} - old, err := caller.acquireLease() - if err != nil { - t.Fatal(err) - } - defer old.Close() - if err := os.Rename(path, path+".old"); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, nil, 0600); err != nil { - t.Fatal(err) - } - restarted := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} - if file, err := restarted.acquireLease(); err == nil { - file.Close() - t.Fatal("replacement inode was adopted") - } - if err := exclusiveLease(path + ".old"); !errors.Is(err, syscall.EWOULDBLOCK) { - t.Fatal("old helper lease lost", err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Rename(path+".old", path); err != nil { - t.Fatal(err) - } - file, err := restarted.acquireLease() - if err != nil { - t.Fatal("original inode refused", err) - } - file.Close() - if err := os.Remove(strings.TrimSuffix(path, ".lease") + ".lease-identity"); err != nil { - t.Fatal(err) - } - if file, err := restarted.acquireLease(); err == nil { - file.Close() - t.Fatal("missing identity adopted") - } -} - -func TestPythonAndGoUseTheSameDurableLease(t *testing.T) { - root := t.TempDir() - installer, err := filepath.Abs("../../../../../deploy/install") - if err != nil { - t.Fatal(err) - } - script := `import sys -sys.path.insert(0,sys.argv[1]) -from pathlib import Path -import node_generations as g,node_install as i -identity={"installation_id":"test-installation","generation":1,"specification_digest":sys.argv[4]} -with g.collection_lease(Path(sys.argv[2]),1,i,identity,initialize=sys.argv[3]=="init"): pass -` - run := func(mode string) error { - return exec.Command("python3", "-c", script, installer, root, mode, testLeaseIdentity().SpecificationDigest).Run() - } - if err := run("init"); err != nil { - t.Fatal("Python initialization", err) - } - path := filepath.Join(root, "state/node/generations/1.lease") - writeTestFinalGeneration(t, path) - caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} - file, err := caller.acquireLease() - if err != nil { - t.Fatal("Go rejected Python identity", err) - } - if err := run("collect"); err == nil { - file.Close() - t.Fatal("Python collected live Go lease") - } - file.Close() - if err := run("collect"); err != nil { - t.Fatal("settled lease not collectible", err) - } - for _, kind := range []string{"symlink", "hardlink", "fifo"} { - record := strings.TrimSuffix(path, ".lease") + ".lease-identity" - original := record + ".original" - if err := os.Rename(record, original); err != nil { - t.Fatal(err) - } - switch kind { - case "symlink": - err = os.Symlink(original, record) - case "hardlink": - err = os.Link(original, record) - case "fifo": - err = syscall.Mkfifo(record, 0600) - } - if err != nil { - t.Fatal(err) - } - if file, err := caller.acquireLease(); err == nil { - file.Close() - t.Fatal("invalid identity accepted", kind) - } - if err := run("collect"); err == nil { - t.Fatal("Python accepted invalid identity", kind) - } - if err := os.Remove(record); err != nil { - t.Fatal(err) - } - if err := os.Rename(original, record); err != nil { - t.Fatal(err) - } - } -} - -func TestHelperRequiresFinalConfigWithoutAnyUnfinishedJournal(t *testing.T) { - path := filepath.Join(t.TempDir(), "1.lease") - writeTestLease(t, path) - caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} - for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { - marker := strings.TrimSuffix(path, ".lease") + suffix - if err := os.WriteFile(marker, []byte("{}"), 0600); err != nil { - t.Fatal(err) - } - if file, err := caller.acquireLease(); err == nil { - file.Close() - t.Fatal("unfinished generation was usable", suffix) - } - if err := os.Remove(marker); err != nil { - t.Fatal(err) - } - } - if err := os.Remove(strings.TrimSuffix(path, ".lease") + ".json"); err != nil { - t.Fatal(err) - } - if file, err := caller.acquireLease(); err == nil { - file.Close() - t.Fatal("unpublished generation was usable") - } - writeTestFinalGeneration(t, path) - file, err := caller.acquireLease() - if err != nil { - t.Fatal(err) - } - file.Close() -} - -func TestOriginalFinalConfigAndWrongGeneration(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, "state/node/generations") - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "1.lease") - writeTestLease(t, path) - if err := os.Rename(filepath.Join(dir, "1.json"), filepath.Join(root, "provider.json")); err != nil { - t.Fatal(err) - } - caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} - file, err := caller.acquireLease() - if err != nil { - t.Fatal("initial published config refused", err) - } - file.Close() - raw, _ := json.Marshal(map[string]any{"installation_id": "test-installation", "generation": 2, "provider": "microsandbox", "specification": sandbox.DeploymentSpec{}}) - if err := os.WriteFile(filepath.Join(root, "provider.json"), raw, 0600); err != nil { - t.Fatal(err) - } - if file, err := caller.acquireLease(); err == nil { - file.Close() - t.Fatal("another generation final config accepted") - } -} diff --git a/services/agents-api/internal/sandbox/microsandbox/provider.go b/services/agents-api/internal/sandbox/microsandbox/provider.go deleted file mode 100644 index 4e787cbd7..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/provider.go +++ /dev/null @@ -1,222 +0,0 @@ -package microsandbox - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var ErrUnconfirmed = sandbox.ErrComputeUnconfirmed - -type Provider struct { - config Config - caller Caller -} - -var _ sandbox.SandboxProvider = (*Provider)(nil) -var _ sandbox.CheckpointProvider = (*Provider)(nil) - -func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } -func NewWithCaller(c Config, caller Caller) (*Provider, error) { - if c.Validate() != nil || caller == nil { - return nil, sandbox.ErrInvalid - } - c.Network.Rules = append([]NetworkRule(nil), c.Network.Rules...) - return &Provider{config: c, caller: caller}, nil -} -func (p *Provider) Initial(ctx context.Context, r sandbox.Reference) (Compute, error) { - if err := ctx.Err(); err != nil { - return Compute{}, err - } - if !ValidReference(r) { - return Compute{}, sandbox.ErrInvalid - } - return Compute{Name: Name(p.config, r, 0)}, nil -} -func (p *Provider) call(ctx context.Context, q Request) (Response, error) { - deadline, ok := ctx.Deadline() - if !ok { - return Response{}, sandbox.ErrInvalid - } - q.Version, q.Config, q.Deadline = ProtocolVersion, p.config, deadline - if err := ValidateRequest(q); err != nil { - return Response{}, err - } - out, err := p.caller.Call(ctx, q) - if err != nil { - if q.Operation == "command" { - return out, errors.Join(sandbox.ErrCommandUnconfirmed, err) - } - return out, errors.Join(ErrUnconfirmed, err) - } - if out.Version != ProtocolVersion { - return out, ErrUnconfirmed - } - switch out.ErrorCode { - case "": - return out, nil - case "invalid": - return out, sandbox.ErrInvalid - case "ownership": - return out, sandbox.ErrOwnership - case "exists": - return out, sandbox.ErrExists - case "not_found": - return out, sandbox.ErrNotFound - case "command_unconfirmed": - return out, sandbox.ErrCommandUnconfirmed - case "metrics_unavailable": - return out, runtimeobs.ErrUnavailable - default: - return out, ErrUnconfirmed - } -} -func (p *Provider) state(ctx context.Context, q Request) (State, error) { - out, e := p.call(ctx, q) - if e != nil { - return State{}, e - } - return p.responseState(ctx, q, out) -} - -func (p *Provider) responseState(ctx context.Context, q Request, out Response) (State, error) { - var e error - if out.State == nil || ValidateCompute(p.config, q.Reference, out.State.Compute) != nil || out.State.Compute.ID == "" { - return State{}, ErrUnconfirmed - } - want := q.Compute - if q.Operation == "create" { - want, e = p.Initial(ctx, q.Reference) - if e != nil { - return State{}, e - } - } - if q.Operation == "suspend" { - want = q.Suspend.Source - } - if q.Operation == "resume" { - want = q.Resume.Target - } - got := out.State.Compute - if (got.RestoredFrom == nil) != (want.RestoredFrom == nil) || (want.RestoredFrom != nil && *got.RestoredFrom != *want.RestoredFrom) { - return State{}, sandbox.ErrOwnership - } - if got.Name != want.Name || got.Generation != want.Generation || (want.ID != "" && got.ID != want.ID) { - return State{}, sandbox.ErrOwnership - } - if q.Operation == "suspend" { - s := out.State.Snapshot - if s == nil && q.Suspend.ObserveOnly && out.State.BootstrapComplete && !out.State.SourceStopped && (out.State.Status == "running" || out.State.Status == "paused") { - return *out.State, nil - } - if s == nil || ValidateSnapshot(p.config, q.Reference, *s) != nil || s.OperationID != q.Suspend.OperationID || s.SourceID != want.ID || (!q.Suspend.ObserveOnly && (!out.State.SourceStopped || out.State.Status != "suspended")) { - return State{}, ErrUnconfirmed - } - } - return *out.State, nil -} -func info(r sandbox.Reference, s State) sandbox.Info { - return sandbox.Info{Reference: r, ProviderID: s.Compute.ID, State: s.Status, BootstrapComplete: s.BootstrapComplete} -} -func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - q := Request{Operation: "create", Reference: b.Reference, Bootstrap: &b} - out, err := p.call(ctx, q) - settledRejection := errors.Is(err, sandbox.ErrInvalid) && !errors.Is(err, ErrUnconfirmed) && out.CreateSettled - if err != nil && !settledRejection { - return sandbox.Info{Reference: b.Reference}, err - } - s, stateErr := p.responseState(ctx, q, out) - if stateErr != nil { - return sandbox.Info{Reference: b.Reference}, stateErr - } - if settledRejection && (s.Status == "" || s.Status == "absent" || s.BootstrapComplete) { - return sandbox.Info{Reference: b.Reference}, ErrUnconfirmed - } - result := info(b.Reference, s) - if settledRejection { - // Settlement is independent of readiness and survives the original error. - result.CreateSettled, result.BootstrapComplete = true, false - } - return result, err -} -func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - c, e := p.Initial(ctx, r) - if e != nil { - return sandbox.Info{}, e - } - s, e := p.GetCompute(ctx, r, c) - return info(r, s), e -} -func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - return p.GetInfo(ctx, r) -} -func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { - c, e := p.Initial(ctx, r) - if e != nil { - return e - } - return p.KillCompute(ctx, r, c) -} -func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - compute, e := p.Initial(ctx, r) - if e != nil { - return sandbox.CommandResult{}, e - } - return p.RunCommandCompute(ctx, r, compute, c) -} -func (p *Provider) GetCompute(ctx context.Context, r sandbox.Reference, c Compute) (State, error) { - return p.state(ctx, Request{Operation: "inspect", Reference: r, Compute: c}) -} -func (p *Provider) KillCompute(ctx context.Context, r sandbox.Reference, c Compute) error { - _, e := p.call(ctx, Request{Operation: "kill", Reference: r, Compute: c}) - return e -} -func (p *Provider) DeleteSnapshot(ctx context.Context, r sandbox.Reference, s SnapshotIdentity) error { - _, e := p.call(ctx, Request{Operation: "delete_snapshot", Reference: r, Snapshot: &s}) - return e -} -func (p *Provider) Suspend(ctx context.Context, q SuspendRequest) (State, error) { - return p.state(ctx, Request{Operation: "suspend", Reference: q.Reference, Suspend: &q}) -} -func (p *Provider) Resume(ctx context.Context, q ResumeRequest) (State, error) { - return p.state(ctx, Request{Operation: "resume", Reference: q.Reference, Resume: &q}) -} -func (p *Provider) RunCommandCompute(ctx context.Context, r sandbox.Reference, c Compute, command sandbox.Command) (sandbox.CommandResult, error) { - out, e := p.call(ctx, Request{Operation: "command", Reference: r, Compute: c, Command: &command}) - if e != nil { - return sandbox.CommandResult{}, e - } - if out.Command == nil || len(out.Command.Stdout) > MaxOutputBytes || len(out.Command.Stderr) > MaxOutputBytes { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - return *out.Command, nil -} - -// ResumeCompute thaws the exact resident source after an aborted suspension. -// It never starts stopped compute or restores a checkpoint. -func (p *Provider) ResumeCompute(ctx context.Context, r sandbox.Reference, c Compute) (State, error) { - return p.state(ctx, Request{Operation: "resume_compute", Reference: r, Compute: c}) -} - -func (p *Provider) NewCompute(ctx context.Context, r sandbox.Reference, generation uint64, snapshot *SnapshotIdentity) (Compute, error) { - if err := ctx.Err(); err != nil { - return Compute{}, err - } - c := Compute{Generation: generation, Name: Name(p.config, r, generation)} - if snapshot != nil { - value := *snapshot - c.RestoredFrom = &value - } - if e := ValidateCompute(p.config, r, c); e != nil { - return Compute{}, e - } - return c, nil -} - -// Quiescent includes a helper that outlived the caller's canceled context. -func (p *Provider) Quiescent() bool { - v, ok := p.caller.(interface{ Quiescent() bool }) - return ok && v.Quiescent() -} diff --git a/services/agents-api/internal/sandbox/microsandbox/provider_test.go b/services/agents-api/internal/sandbox/microsandbox/provider_test.go deleted file mode 100644 index 0ebd102ed..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/provider_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package microsandbox - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -type callerFunc func(context.Context, Request) (Response, error) - -func (f callerFunc) Call(c context.Context, q Request) (Response, error) { return f(c, q) } -func testConfig() Config { - return Config{ - InstallationID: "11111111-1111-4111-8111-111111111111", HelperPath: "/helper", RuntimeHome: "/private/msb", RuntimePath: "/private/bin/msb", FirmwarePath: "/private/lib/libkrunfw.so", - RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), Image: "registry/runtime@sha256:" + strings.Repeat("c", 64), - MemoryMiB: 2048, CPUs: 2, RootDiskMiB: 4096, EnvironmentDiskMiB: 2048, Network: NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny", Rules: []NetworkRule{{Action: "allow", Direction: "egress", Destination: "host"}}}, - } -} -func testRef() sandbox.Reference { - return sandbox.Reference{TenantID: "22222222-2222-4222-8222-222222222222", EnvironmentID: "33333333-3333-4333-8333-333333333333", AllocationID: "44444444-4444-4444-8444-444444444444"} -} -func testSnapshot() SnapshotIdentity { - c, r := testConfig(), testRef() - op := "55555555-5555-4555-8555-555555555555" - return SnapshotIdentity{Reference: SnapshotReference(c, r, op), ID: "snap_exact", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"} -} -func deadline(t *testing.T) context.Context { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - t.Cleanup(cancel) - return ctx -} -func TestRejectsChangedComputeIdentity(t *testing.T) { - c, r := testConfig(), testRef() - p, e := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { - got := q.Compute - got.ID = "local:foreign" - return Response{Version: ProtocolVersion, State: &State{Compute: got, Status: "running", BootstrapComplete: true}}, nil - })) - if e != nil { - t.Fatal(e) - } - _, e = p.GetCompute(deadline(t), r, Compute{Name: Name(c, r, 0), ID: "local:4"}) - if !errors.Is(e, sandbox.ErrOwnership) { - t.Fatalf("foreign identity accepted: %v", e) - } -} -func TestRestoreMustRetainExactProvenance(t *testing.T) { - c, r, s := testConfig(), testRef(), testSnapshot() - target := Compute{Generation: 1, Name: Name(c, r, 1), RestoredFrom: &s} - p, _ := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { - v := *q.Resume.Target.RestoredFrom - v.ID = "snap_foreign" - got := q.Resume.Target - got.ID = "local:5" - got.RestoredFrom = &v - return Response{Version: ProtocolVersion, State: &State{Compute: got, Status: "running", BootstrapComplete: true}}, nil - })) - _, e := p.Resume(deadline(t), ResumeRequest{Reference: r, OperationID: "66666666-6666-4666-8666-666666666666", Snapshot: s, Target: target}) - if !errors.Is(e, sandbox.ErrOwnership) { - t.Fatalf("different snapshot accepted: %v", e) - } -} -func TestRejectsSnapshotPathBeforeHelper(t *testing.T) { - c, r, s := testConfig(), testRef(), testSnapshot() - s.Reference = "/foreign/checkpoint" - calls := 0 - p, _ := NewWithCaller(c, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) - if e := p.DeleteSnapshot(deadline(t), r, s); !errors.Is(e, sandbox.ErrInvalid) || calls != 0 { - t.Fatalf("e=%v calls=%d", e, calls) - } -} -func TestObserveOnlyPreservesCapturedButResidentState(t *testing.T) { - c, r, s := testConfig(), testRef(), testSnapshot() - source := Compute{Name: Name(c, r, 0), ID: s.SourceID} - p, _ := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { - if q.Suspend == nil || !q.Suspend.ObserveOnly { - t.Fatal("observation became mutation") - } - return Response{Version: ProtocolVersion, State: &State{Compute: source, Status: "paused", Snapshot: &s}}, nil - })) - got, e := p.Suspend(deadline(t), SuspendRequest{Reference: r, OperationID: s.OperationID, Source: source, ObserveOnly: true}) - if e != nil || got.SourceStopped || got.Status != "paused" { - t.Fatalf("state=%+v error=%v", got, e) - } -} -func TestCommandUncertaintyAndResultLimits(t *testing.T) { - for _, tc := range []struct { - name string - response Response - failure error - }{ - {"transport", Response{}, errors.New("lost result")}, - {"missing", Response{Version: ProtocolVersion}, nil}, - {"too_large", Response{Version: ProtocolVersion, Command: &sandbox.CommandResult{Stdout: strings.Repeat("x", MaxOutputBytes+1)}}, nil}, - } { - t.Run(tc.name, func(t *testing.T) { - p, _ := NewWithCaller(testConfig(), callerFunc(func(context.Context, Request) (Response, error) { return tc.response, tc.failure })) - _, e := p.RunCommand(deadline(t), testRef(), sandbox.Command{Args: []string{"/bin/true"}}) - if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { - t.Fatalf("uncertainty lost: %v", e) - } - }) - } -} -func TestNoDeadlineOrForeignAllocationNeverCallsHelper(t *testing.T) { - calls := 0 - p, _ := NewWithCaller(testConfig(), callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) - _, e := p.GetInfo(context.Background(), testRef()) - if !errors.Is(e, sandbox.ErrInvalid) { - t.Fatal(e) - } - r := testRef() - foreign := r - foreign.EnvironmentID = "77777777-7777-4777-8777-777777777777" - initial, initialErr := p.Initial(deadline(t), r) - if initialErr != nil { - t.Fatal(initialErr) - } - _, e = p.GetCompute(deadline(t), foreign, initial) - if !errors.Is(e, sandbox.ErrInvalid) || calls != 0 { - t.Fatalf("e=%v calls=%d", e, calls) - } -} -func TestHelperEnvironmentDropsCredentialsAndBackendOverrides(t *testing.T) { - got := strings.Join(HelperEnvironment([]string{"HOME=/home/test", "PATH=/bin", "MSB_BACKEND=cloud", "MSB_CONFIG_PATH=/foreign", "MICROSANDBOX_FFI_PATH=/foreign.so", "OPENAI_API_KEY=secret", "HTTPS_PROXY=secret"}, testConfig()), "\n") - for _, bad := range []string{"secret", "cloud", "foreign"} { - if strings.Contains(got, bad) { - t.Fatalf("ambient override retained: %s", got) - } - } - if !strings.Contains(got, "MSB_BACKEND=local") { - t.Fatal("missing local backend") - } -} - -func TestMissingSnapshotObservationAllowsOnlyIntactSourceRollback(t *testing.T) { - for _, status := range []string{"running", "paused", "stopped", "draining", "unknown"} { - t.Run(status, func(t *testing.T) { - c, r, s := testConfig(), testRef(), testSnapshot() - source := Compute{Name: Name(c, r, 0), ID: s.SourceID} - p, _ := NewWithCaller(c, callerFunc(func(context.Context, Request) (Response, error) { - return Response{Version: ProtocolVersion, State: &State{Compute: source, Status: status, BootstrapComplete: true}}, nil - })) - _, e := p.Suspend(deadline(t), SuspendRequest{Reference: r, OperationID: s.OperationID, Source: source, ObserveOnly: true}) - if status == "running" || status == "paused" { - if e != nil { - t.Fatal(e) - } - } else if e == nil { - t.Fatal("unrecoverable source accepted") - } - }) - } -} -func TestImageMustUseCompletePinnedOCIManifestDigest(t *testing.T) { - for _, image := range []string{"image:latest", "image@sha256:garbage", "sha256:" + strings.Repeat("a", 64), "image@sha256:" + strings.Repeat("A", 64)} { - c := testConfig() - c.Image = image - if c.Validate() == nil { - t.Fatalf("unqualified image admitted: %s", image) - } - } -} diff --git a/services/agents-api/internal/sandbox/microsandbox/resources.go b/services/agents-api/internal/sandbox/microsandbox/resources.go deleted file mode 100644 index 02821c98c..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/resources.go +++ /dev/null @@ -1,76 +0,0 @@ -package microsandbox - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var _ runtimeobs.Source = (*Provider)(nil) - -func (*Provider) ObservationProviderType() string { return "microsandbox" } - -// Observe reads one point-in-time native metrics snapshot through the existing -// one-shot helper. The persisted compute receipt selects the exact generation; -// browser input and provider display names never select a sandbox. -func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { - if target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID == "" { - return runtimeobs.Sample{}, sandbox.ErrInvalid - } - if target.Instance.ProviderKey != p.config.InstallationID { - return runtimeobs.Sample{}, sandbox.ErrOwnership - } - reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} - if target.Instance.ComputePhase == "suspended" { - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - } - var state struct { - Current Compute `json:"current"` - } - if json.Unmarshal(target.Instance.ProviderState, &state) != nil || state.Current.ID == "" { - // Suspension-disabled allocations predate durable compute receipts. A - // deterministic name is not an incarnation fence, so do not sample it. - if target.Instance.ComputePhase == "disabled" { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - return runtimeobs.Sample{}, sandbox.ErrOwnership - } - if ValidateCompute(p.config, reference, state.Current) != nil { - return runtimeobs.Sample{}, sandbox.ErrOwnership - } - out, err := p.call(ctx, Request{Operation: "metrics", Reference: reference, Compute: state.Current}) - if errors.Is(err, sandbox.ErrNotFound) { - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - } - if err != nil { - return runtimeobs.Sample{}, err - } - if out.Metrics == nil { - return runtimeobs.Sample{}, ErrUnconfirmed - } - return sampleFromMetrics(p.config, *out.Metrics) -} - -func sampleFromMetrics(config Config, metrics Metrics) (runtimeobs.Sample, error) { - if metrics.ObservedAt.IsZero() || metrics.Uptime < 0 || metrics.MemoryLimitBytes == 0 || config.CPUs == 0 { - return runtimeobs.Sample{}, ErrUnconfirmed - } - // Both values come from one native registry sample at millisecond precision. - // Helper wall time and the SDK's rounded uptime cannot establish this fence. - startedAt := metrics.ObservedAt.Add(-metrics.Uptime) - if startedAt.Unix() < 0 || startedAt.After(metrics.ObservedAt) { - return runtimeobs.Sample{}, ErrUnconfirmed - } - cpuUsage := float64(metrics.VCPUTimeNs) / float64(time.Second) - cpuCapacity := float64(config.CPUs) - memoryUsage, memoryLimit := metrics.MemoryBytes, metrics.MemoryLimitBytes - return runtimeobs.Sample{ - ObservedAt: metrics.ObservedAt, StartedAt: &startedAt, - CPUUsageSecondsTotal: &cpuUsage, CPUCapacityCores: &cpuCapacity, - MemoryUsageBytes: &memoryUsage, MemoryLimitBytes: &memoryLimit, - }, nil -} diff --git a/services/agents-api/internal/sandbox/microsandbox/resources_test.go b/services/agents-api/internal/sandbox/microsandbox/resources_test.go deleted file mode 100644 index 819e251d1..000000000 --- a/services/agents-api/internal/sandbox/microsandbox/resources_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package microsandbox - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func observationTarget(t *testing.T, compute Compute) runtimeobs.Target { - t.Helper() - state, err := json.Marshal(struct { - Current Compute `json:"current"` - }{Current: compute}) - if err != nil { - t.Fatal(err) - } - r := testRef() - return runtimeobs.Target{ - TenantID: r.TenantID, SessionID: "55555555-5555-4555-8555-555555555555", EnvironmentID: r.EnvironmentID, Mode: runtimeobs.ModeManaged, - Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: testConfig().InstallationID, AllocationState: "running", ComputePhase: "running", ProviderState: state}, - } -} - -func TestObserveUsesPersistedExactComputeAndNormalizesMetrics(t *testing.T) { - config, reference := testConfig(), testRef() - compute := Compute{Name: Name(config, reference, 3), ID: "local:current", Generation: 3, RestoredFrom: func() *SnapshotIdentity { value := testSnapshot(); return &value }()} - // The snapshot fixture belongs to generation zero and is valid provenance for generation three. - observed := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) - provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { - if request.Operation != "metrics" || request.Reference != reference || request.Compute.ID != compute.ID || request.Compute.Generation != compute.Generation { - t.Fatalf("unexpected metrics request: %+v", request) - } - return Response{Version: ProtocolVersion, Metrics: &Metrics{ - ObservedAt: observed, Uptime: 5 * time.Minute, VCPUTimeNs: 2_500_000_000, - MemoryBytes: 1024, MemoryLimitBytes: 2 * 1024 * 1024, - }}, nil - })) - if err != nil { - t.Fatal(err) - } - sample, err := provider.Observe(deadline(t), observationTarget(t, compute)) - if err != nil { - t.Fatal(err) - } - if sample.StartedAt == nil || !sample.StartedAt.Equal(observed.Add(-5*time.Minute)) || sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 2.5 || sample.CPUCapacityCores == nil || *sample.CPUCapacityCores != 2 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 1024 || sample.MemoryLimitBytes == nil || *sample.MemoryLimitBytes != 2*1024*1024 { - t.Fatalf("bad normalized sample: %+v", sample) - } -} - -func TestObserveRejectsForeignOrMissingComputeBeforeHelper(t *testing.T) { - config, reference := testConfig(), testRef() - compute := Compute{Name: Name(config, reference, 0), ID: "local:current"} - calls := 0 - provider, _ := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) - - foreign := observationTarget(t, compute) - foreign.Instance.ProviderKey = "66666666-6666-4666-8666-666666666666" - if _, err := provider.Observe(deadline(t), foreign); !errors.Is(err, sandbox.ErrOwnership) { - t.Fatalf("foreign installation accepted: %v", err) - } - missing := observationTarget(t, compute) - missing.Instance.ProviderState = json.RawMessage(`{}`) - if _, err := provider.Observe(deadline(t), missing); !errors.Is(err, sandbox.ErrOwnership) { - t.Fatalf("missing compute accepted: %v", err) - } - if calls != 0 { - t.Fatalf("invalid identity reached helper: %d calls", calls) - } -} - -func TestObserveWithoutExactReceiptAndSuspendedDoesNotWake(t *testing.T) { - config := testConfig() - calls := 0 - provider, _ := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { - calls++ - return Response{}, nil - })) - target := observationTarget(t, Compute{Name: Name(config, testRef(), 0), ID: "unused"}) - target.Instance.ComputePhase, target.Instance.ProviderState = "disabled", nil - if _, err := provider.Observe(deadline(t), target); !errors.Is(err, runtimeobs.ErrUnavailable) { - t.Fatalf("receipt-less compute was sampled: %v", err) - } - target.Instance.ComputePhase = "suspended" - if _, err := provider.Observe(deadline(t), target); !errors.Is(err, runtimeobs.ErrNotRunning) { - t.Fatalf("suspended compute was not unavailable: %v", err) - } - if calls != 0 { - t.Fatalf("unfenced compute reached helper: %d calls", calls) - } -} - -func TestObserveMapsStoppedAndMetricsUnavailable(t *testing.T) { - config, reference := testConfig(), testRef() - compute := Compute{Name: Name(config, reference, 0), ID: "local:current"} - for _, test := range []struct { - name string - response Response - want error - }{ - {name: "stopped", response: Response{Version: ProtocolVersion, ErrorCode: "not_found"}, want: runtimeobs.ErrNotRunning}, - {name: "metrics unavailable", response: Response{Version: ProtocolVersion, ErrorCode: "metrics_unavailable"}, want: runtimeobs.ErrUnavailable}, - } { - t.Run(test.name, func(t *testing.T) { - provider, _ := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { return test.response, nil })) - if _, err := provider.Observe(deadline(t), observationTarget(t, compute)); !errors.Is(err, test.want) { - t.Fatalf("error=%v want=%v", err, test.want) - } - }) - } -} - -func TestSampleFromMetricsRejectsInvalidTimingAndLimit(t *testing.T) { - config := testConfig() - for _, metrics := range []Metrics{ - {}, - {ObservedAt: time.Unix(1, 0), Uptime: -time.Second, MemoryLimitBytes: 1}, - {ObservedAt: time.Unix(1, 0), Uptime: 2 * time.Second, MemoryLimitBytes: 1}, - {ObservedAt: time.Unix(1, 0), MemoryLimitBytes: 0}, - } { - if _, err := sampleFromMetrics(config, metrics); err == nil { - t.Fatalf("invalid metrics accepted: %+v", metrics) - } - } -} - -func TestObserveKeepsIncarnationAcrossPollsAndCoreRestart(t *testing.T) { - config, reference := testConfig(), testRef() - compute := Compute{Name: Name(config, reference, 0), ID: "local:first"} - startedAt := time.Date(2026, 9, 22, 12, 0, 0, 122000000, time.UTC) - var previous runtimeobs.Sample - for index, uptime := range []time.Duration{300001 * time.Millisecond, 301877 * time.Millisecond, time.Millisecond} { - currentStart := startedAt - cpu := uint64(2_500_000_000 + index*1_000_000_000) - if index == 2 { - compute = Compute{Name: Name(config, reference, 1), ID: "local:restored", Generation: 1, RestoredFrom: func() *SnapshotIdentity { s := testSnapshot(); return &s }()} - currentStart = startedAt.Add(5 * time.Minute) - cpu = 1_000_000 - } - // Each poll uses a fresh Core provider, with no process-local time cache. - provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { - if request.Compute.ID != compute.ID || request.Compute.Generation != compute.Generation { - t.Fatalf("wrong compute: %+v", request.Compute) - } - return Response{Version: ProtocolVersion, Metrics: &Metrics{ObservedAt: currentStart.Add(uptime), Uptime: uptime, VCPUTimeNs: cpu, MemoryLimitBytes: 8192}}, nil - })) - if err != nil { - t.Fatal(err) - } - sample, err := provider.Observe(deadline(t), observationTarget(t, compute)) - if err != nil { - t.Fatal(err) - } - if sample.StartedAt == nil || !sample.StartedAt.Equal(currentStart) { - t.Fatalf("wrong start: %+v", sample) - } - if index == 1 { - if !sample.StartedAt.Equal(*previous.StartedAt) || *sample.CPUUsageSecondsTotal-*previous.CPUUsageSecondsTotal != 1 { - t.Fatal("repeated polls lost the CPU delta") - } - } - if index == 2 && sample.StartedAt.Equal(*previous.StartedAt) { - t.Fatal("restored compute reused the source incarnation") - } - previous = sample - } -} diff --git a/services/agents-api/internal/sandbox/node/connection_test.go b/services/agents-api/internal/sandbox/node/connection_test.go deleted file mode 100644 index 3a1558214..000000000 --- a/services/agents-api/internal/sandbox/node/connection_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package node - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/gorilla/websocket" -) - -func nodeEndpoint(origin, id string) string { - return "ws" + strings.TrimPrefix(origin, "http") + "?node_id=" + id -} -func assertDuplicateRejected(t *testing.T, origin, id, credential string) { - t.Helper() - conn, response, err := websocket.DefaultDialer.Dial(nodeEndpoint(origin, id), http.Header{"Authorization": []string{"Bearer " + credential}}) - if conn != nil { - conn.Close() - } - if response != nil { - defer response.Body.Close() - } - if err == nil || response == nil || response.StatusCode != http.StatusConflict { - t.Fatalf("duplicate connection was not rejected with 409: response=%v err=%v", responseStatus(response), err) - } -} -func responseStatus(r *http.Response) int { - if r == nil { - return 0 - } - return r.StatusCode -} -func reservationReleased(h *Hub, id string) bool { - h.mu.Lock() - defer h.mu.Unlock() - _, held := h.reservations[id] - return !held -} -func connectRawNode(t *testing.T, origin string, id Identity) *websocket.Conn { - t.Helper() - conn, _, err := websocket.DefaultDialer.Dial(nodeEndpoint(origin, id.NodeID), http.Header{"Authorization": []string{"Bearer test"}}) - if err != nil { - t.Fatal(err) - } - if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { - conn.Close() - t.Fatal(err) - } - if _, err = readFrame(conn); err != nil { - conn.Close() - t.Fatal(err) - } - return conn -} - -func TestHubReservesIdentityAcrossConcurrentOpeningHandshakes(t *testing.T) { - id := identity() - var connected atomic.Int32 - hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Connected: func(context.Context, Identity, string, uint64) error { connected.Add(1); return nil }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - type result struct { - conn *websocket.Conn - status int - err error - } - results := make(chan result, 2) - start := make(chan struct{}) - for range 2 { - go func() { - <-start - conn, response, err := websocket.DefaultDialer.Dial(nodeEndpoint(server.URL, id.NodeID), http.Header{"Authorization": []string{"Bearer test"}}) - status := responseStatus(response) - if response != nil { - response.Body.Close() - } - results <- result{conn, status, err} - }() - } - close(start) - var winner *websocket.Conn - accepted, rejected := 0, 0 - for range 2 { - r := <-results - if r.err == nil { - accepted++ - winner = r.conn - } else if r.status == http.StatusConflict { - rejected++ - } else { - t.Errorf("unexpected handshake result: status=%d err=%v", r.status, r.err) - } - } - if winner != nil { - defer winner.Close() - } - if accepted != 1 || rejected != 1 || connected.Load() != 0 { - t.Fatalf("opening identity was not exclusive: accepted=%d rejected=%d callbacks=%d", accepted, rejected, connected.Load()) - } - // No hello was sent: even an unfinished handshake owns the reservation, and - // its failure must release it without touching persisted node presence. - assertDuplicateRejected(t, server.URL, id.NodeID, "test") - winner.Close() - wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) - conn := connectRawNode(t, server.URL, id) - defer conn.Close() - wait(t, func() bool { return hub.Online(id.NodeID) }) - if connected.Load() != 1 { - t.Fatal("failed handshake changed node presence") - } -} - -func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { - id := identity() - var credential string - var authenticated, connected atomic.Int32 - duplicateAttempt := make(chan struct{}, 1) - hub := NewHub(HubOptions{Authenticate: func(ctx context.Context, node, token string) (Identity, error) { - if node != id.NodeID || token != credential { - return Identity{}, ErrAuthentication - } - if authenticated.Add(1) > 1 { - select { - case duplicateAttempt <- struct{}{}: - default: - } - } - return id, nil - }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Connected: func(context.Context, Identity, string, uint64) error { connected.Add(1); return nil }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - credential = stored.Credential - duplicateDir := stateDir(t) - if err = writeIdentity(duplicateDir, stored); err != nil { - t.Fatal(err) - } - original := &fakeProvider{started: make(chan struct{}), release: make(chan struct{})} - duplicate := &fakeProvider{} - var release sync.Once - defer release.Do(func() { close(original.release) }) - start := func(dir string, p sandbox.SandboxProvider) (context.CancelFunc, chan error) { - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: credential, Provider: p, Probe: probe}) - }() - return cancel, done - } - stopOriginal, originalDone := start(dir, original) - defer func() { - release.Do(func() { close(original.release) }) - stopOriginal() - if err := <-originalDone; err != nil { - t.Error(err) - } - }() - wait(t, func() bool { return hub.Online(id.NodeID) }) - hub.mu.Lock() - first := hub.peers[id.NodeID] - hub.mu.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - proxy := hub.Proxy(id.NodeID, "docker", 1) - r := reference() - created := make(chan error, 1) - go func() { _, err := proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); created <- err }() - <-original.started - stopDuplicate, duplicateDone := start(duplicateDir, duplicate) - defer func() { - stopDuplicate() - if err := <-duplicateDone; err != nil { - t.Error(err) - } - }() - select { - case <-duplicateAttempt: - case <-time.After(time.Second): - t.Fatal("duplicate did not attempt connection") - } - for range 3 { - assertDuplicateRejected(t, server.URL, id.NodeID, credential) - } - hub.mu.Lock() - retained := hub.peers[id.NodeID] == first - hub.mu.Unlock() - if !retained || connected.Load() != 1 { - t.Fatal("duplicate replaced original connection or persisted its presence") - } - release.Do(func() { close(original.release) }) - if err = <-created; err != nil { - t.Fatal(err) - } - if _, err = proxy.GetInfo(ctx, r); err != nil { - t.Fatal(err) - } - original.mu.Lock() - creates, reads := original.creates, original.reads - original.mu.Unlock() - duplicate.mu.Lock() - duplicateEffects := duplicate.creates + duplicate.reads + duplicate.kills - duplicate.mu.Unlock() - if creates != 1 || reads != 1 || duplicateEffects != 0 { - t.Fatalf("work moved/replayed: creates=%d reads=%d duplicate=%d", creates, reads, duplicateEffects) - } -} - -func TestReservationRemainsUntilFencedDisconnectFinishes(t *testing.T) { - id := identity() - retiring := make(chan struct{}) - finish := make(chan struct{}) - var once sync.Once - defer once.Do(func() { close(finish) }) - var mu sync.Mutex - active := "" - connections := 0 - var callbackErr error - hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 9, nil }, Connected: func(ctx context.Context, id Identity, connection string, epoch uint64) error { - mu.Lock() - defer mu.Unlock() - if epoch != 9 { - callbackErr = errors.New("incorrect connect epoch") - } - active = connection - connections++ - return nil - }, Disconnected: func(ctx context.Context, id Identity, connection string, epoch uint64) { - mu.Lock() - first := connections == 1 - mu.Unlock() - if first { - close(retiring) - <-finish - } - mu.Lock() - defer mu.Unlock() - if epoch != 9 { - callbackErr = errors.New("incorrect disconnect epoch") - } - if active == connection { - active = "" - } - }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - first := connectRawNode(t, server.URL, id) - wait(t, func() bool { return hub.Online(id.NodeID) }) - first.Close() - select { - case <-retiring: - case <-time.After(time.Second): - t.Fatal("disconnect callback did not start") - } - assertDuplicateRejected(t, server.URL, id.NodeID, "test") - mu.Lock() - count := connections - mu.Unlock() - if count != 1 { - t.Fatal("new connection admitted before disconnect settled") - } - once.Do(func() { close(finish) }) - wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) - next := connectRawNode(t, server.URL, id) - defer next.Close() - wait(t, func() bool { return hub.Online(id.NodeID) }) - hub.mu.Lock() - current := hub.peers[id.NodeID].id - hub.mu.Unlock() - mu.Lock() - defer mu.Unlock() - if active != current || connections != 2 || callbackErr != nil { - t.Fatalf("stale disconnect cleared current presence: count=%d error=%v", connections, callbackErr) - } -} diff --git a/services/agents-api/internal/sandbox/node/creation_settlement_test.go b/services/agents-api/internal/sandbox/node/creation_settlement_test.go deleted file mode 100644 index 02d56f13c..000000000 --- a/services/agents-api/internal/sandbox/node/creation_settlement_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package node - -import ( - "context" - "errors" - "net/http/httptest" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -type settledProvider struct { - fakeProvider - info sandbox.Info - err error -} - -func (p *settledProvider) Create(context.Context, sandbox.Bootstrap) (sandbox.Info, error) { - return p.info, p.err -} -func (p *settledProvider) GetInfo(context.Context, sandbox.Reference) (sandbox.Info, error) { - return p.info, p.err -} - -func TestNodeCarriesCreationSettlementWithoutConvertingFailureToSuccess(t *testing.T) { - ref := reference() - for _, test := range []struct { - name string - info sandbox.Info - err error - keep bool - }{ - {"rejected before bootstrap", sandbox.Info{Reference: ref, ProviderID: "owned", CreateSettled: true}, sandbox.ErrInvalid, true}, - {"confirmed absent", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true}, sandbox.ErrInvalid, true}, - {"observed absent", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true}, nil, true}, - {"unsettled error", sandbox.Info{Reference: ref, ProviderID: "owned"}, sandbox.ErrInvalid, false}, - {"foreign settlement", sandbox.Info{Reference: reference(), ProviderID: "foreign", CreateSettled: true}, sandbox.ErrInvalid, false}, - {"absent but ready", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true, BootstrapComplete: true}, sandbox.ErrInvalid, false}, - {"missing compute identity", sandbox.Info{Reference: ref, CreateSettled: true}, sandbox.ErrInvalid, false}, - } { - t.Run(test.name, func(t *testing.T) { - id := identity() - hub := NewHub(HubOptions{ - Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, - OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, - }) - defer hub.Close() - server := httptest.NewServer(hub) - defer server.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, - Provider: &settledProvider{info: test.info, err: test.err}, Probe: probe}) - }() - defer func() { - cancel() - select { - case err := <-done: - if err != nil { - t.Error(err) - } - case <-time.After(5 * time.Second): - t.Error("node did not stop") - } - }() - wait(t, func() bool { return hub.Online(id.NodeID) }) - proxy := hub.Proxy(id.NodeID, "docker", 1) - for _, operation := range []func(context.Context) (sandbox.Info, error){ - func(ctx context.Context) (sandbox.Info, error) { - return proxy.Create(ctx, sandbox.Bootstrap{Reference: ref}) - }, - func(ctx context.Context) (sandbox.Info, error) { return proxy.GetInfo(ctx, ref) }, - } { - call, stop := context.WithTimeout(ctx, 3*time.Second) - info, err := operation(call) - stop() - if !errors.Is(err, test.err) { - t.Fatalf("operation error changed: %v", err) - } - if test.keep && info != test.info || !test.keep && info != (sandbox.Info{}) { - t.Fatalf("incorrect settlement forwarding: %+v", info) - } - } - }) - } -} diff --git a/services/agents-api/internal/sandbox/node/generations.go b/services/agents-api/internal/sandbox/node/generations.go deleted file mode 100644 index fc5707875..000000000 --- a/services/agents-api/internal/sandbox/node/generations.go +++ /dev/null @@ -1,387 +0,0 @@ -package node - -import ( - "context" - "errors" - "sort" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -type GenerationProvider struct { - Generation uint64 - SpecificationDigest string - Provider sandbox.SandboxProvider - Probe func(context.Context) error - Close func() -} - -type GenerationManagerOptions struct { - Initial []GenerationProvider - Recover []sandbox.GenerationReference - Collect []sandbox.GenerationReference - Prepare func(context.Context, uint64, string) (GenerationProvider, error) - Remove func(context.Context, GenerationProvider) error -} - -type localGeneration struct { - value GenerationProvider - state, diagnostic string - refs int - retryAt time.Time - failures int - removing bool - repairing, preparing bool - collecting bool -} - -// GenerationManager owns local providers, preparation and all queued/in-flight -// references. Wire metadata is sparse; only correlated Core grants call Drop. -type GenerationManager struct { - mu sync.Mutex - values map[uint64]*localGeneration - target sandbox.NodeDeployment - statusCursor, probeCursor, recoveryCursor uint64 - priorityProbe uint64 - options GenerationManagerOptions - ctx context.Context - cancel context.CancelFunc - preparingCancel context.CancelFunc - wake chan struct{} - workers sync.WaitGroup -} - -func NewGenerationManager(ctx context.Context, options GenerationManagerOptions) (*GenerationManager, error) { - if options.Prepare == nil || options.Remove == nil { - return nil, sandbox.ErrInvalid - } - owned, cancel := context.WithCancel(ctx) - m := &GenerationManager{values: map[uint64]*localGeneration{}, options: options, ctx: owned, cancel: cancel, wake: make(chan struct{}, 1)} - for _, v := range options.Initial { - if !validGeneration(v.Generation) || !validSpecificationDigest(v.SpecificationDigest) || sandbox.ValidateProvider(v.Provider) != nil || v.Probe == nil || m.values[v.Generation] != nil { - cancel() - return nil, sandbox.ErrInvalid - } - m.values[v.Generation] = &localGeneration{value: v, state: "preparing"} - } - for _, ref := range options.Recover { - if !validGeneration(ref.Generation) || !validSpecificationDigest(ref.SpecificationDigest) || m.values[ref.Generation] != nil { - cancel() - return nil, sandbox.ErrInvalid - } - m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, state: "failed", diagnostic: sandbox.NodeProviderUnavailable, repairing: true} - } - for _, ref := range options.Collect { - if !validGeneration(ref.Generation) || !validSpecificationDigest(ref.SpecificationDigest) || m.values[ref.Generation] != nil { - cancel() - return nil, sandbox.ErrInvalid - } - m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, collecting: true} - } - m.workers.Add(2) - go func() { defer m.workers.Done(); m.prepareLoop() }() - go func() { defer m.workers.Done(); m.probeLoop() }() - return m, nil -} - -func (m *GenerationManager) Close() { - m.cancel() - m.workers.Wait() - m.mu.Lock() - defer m.mu.Unlock() - for _, g := range m.values { - if g.value.Close != nil { - g.value.Close() - } - } -} - -func (m *GenerationManager) Deployment(value sandbox.NodeDeployment) error { - m.mu.Lock() - defer m.mu.Unlock() - if !validGeneration(value.Generation) || !validSpecificationDigest(value.SpecificationDigest) || value.ServingGeneration != nil && (!validGeneration(*value.ServingGeneration) || *value.ServingGeneration > value.Generation) { - return sandbox.ErrOwnership - } - if value.Generation < m.target.Generation { - return nil - } - if g := m.values[value.Generation]; g != nil && (g.value.SpecificationDigest != value.SpecificationDigest || g.collecting) { - return sandbox.ErrOwnership - } - if value.ServingGeneration != nil { - if g := m.values[*value.ServingGeneration]; g != nil && g.collecting { - return sandbox.ErrOwnership - } - } - if value.Generation == m.target.Generation && value.SpecificationDigest != m.target.SpecificationDigest { - return sandbox.ErrOwnership - } - if value.Generation != m.target.Generation && m.preparingCancel != nil { - m.preparingCancel() - } - m.target = value - if g := m.values[value.Generation]; g == nil { - m.values[value.Generation] = &localGeneration{value: GenerationProvider{Generation: value.Generation, SpecificationDigest: value.SpecificationDigest}, state: "preparing"} - } - select { - case m.wake <- struct{}{}: - default: - } - return nil -} - -func (m *GenerationManager) orderedLocked(after uint64) []uint64 { - keys := make([]uint64, 0, len(m.values)) - for g, v := range m.values { - if !v.removing { - keys = append(keys, g) - } - } - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) - pivot := sort.Search(len(keys), func(i int) bool { return keys[i] > after }) - return append(keys[pivot:], keys[:pivot]...) -} - -func (m *GenerationManager) Statuses() []sandbox.GenerationStatus { - m.mu.Lock() - defer m.mu.Unlock() - keys := []uint64{m.target.Generation} - if m.target.ServingGeneration != nil { - keys = append(keys, *m.target.ServingGeneration) - } - keys = append(keys, m.orderedLocked(m.statusCursor)...) - result := make([]sandbox.GenerationStatus, 0, 8) - seen := map[uint64]bool{} - for _, key := range keys { - g := m.values[key] - if g == nil || g.removing || g.collecting || seen[key] { - continue - } - seen[key] = true - result = append(result, sandbox.GenerationStatus{Generation: key, SpecificationDigest: g.value.SpecificationDigest, State: g.state, Diagnostic: g.diagnostic}) - if key != m.target.Generation && (m.target.ServingGeneration == nil || key != *m.target.ServingGeneration) { - m.statusCursor = key - } - if len(result) == 8 { - break - } - } - return result -} - -func (m *GenerationManager) Retained(after uint64) []sandbox.GenerationReference { - m.mu.Lock() - defer m.mu.Unlock() - keys := m.orderedLocked(after) - result := make([]sandbox.GenerationReference, 0, 8) - for _, key := range keys { - g := m.values[key] - result = append(result, sandbox.GenerationReference{Generation: key, SpecificationDigest: g.value.SpecificationDigest}) - if len(result) == 8 { - break - } - } - return result -} - -// Acquire is called before queue admission, so queued work also prevents GC. -func (m *GenerationManager) Acquire(generation uint64) (sandbox.SandboxProvider, bool, func(), error) { - m.mu.Lock() - defer m.mu.Unlock() - g := m.values[generation] - if g == nil || g.removing || g.collecting || g.preparing || g.value.Provider == nil { - return nil, false, nil, ErrUnavailable - } - g.refs++ - var once sync.Once - release := func() { once.Do(func() { m.mu.Lock(); g.refs--; m.mu.Unlock() }) } - return g.value.Provider, g.state == "ready", release, nil -} - -func (m *GenerationManager) Drop(ctx context.Context, grant sandbox.GenerationRetention) error { - if err := ctx.Err(); err != nil { - return err - } - if grant.Keep { - return nil - } - m.mu.Lock() - g := m.values[grant.Generation] - if g == nil { - m.mu.Unlock() - return nil - } - if g.value.SpecificationDigest != grant.SpecificationDigest { - m.mu.Unlock() - return sandbox.ErrOwnership - } - quiet := true - if provider, ok := g.value.Provider.(interface{ Quiescent() bool }); ok { - quiet = provider.Quiescent() - } - if !quiet || g.refs != 0 || g.removing || m.target.Generation == grant.Generation || m.target.ServingGeneration != nil && *m.target.ServingGeneration == grant.Generation { - m.mu.Unlock() - return ErrUnavailable - } - g.collecting = true - g.removing = true - m.mu.Unlock() - err := m.options.Remove(ctx, g.value) - m.mu.Lock() - defer m.mu.Unlock() - if err != nil { - g.removing = false - return err - } - if g.value.Close != nil { - g.value.Close() - } - delete(m.values, grant.Generation) - return nil -} - -func (m *GenerationManager) prepareLoop() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - for { - select { - case <-m.ctx.Done(): - return - case <-m.wake: - case <-ticker.C: - } - m.mu.Lock() - var g *localGeneration - if m.target.Generation != 0 { - keys := []uint64{m.target.Generation} - if m.target.ServingGeneration != nil { - keys = append(keys, *m.target.ServingGeneration) - } - keys = append(keys, m.orderedLocked(m.recoveryCursor)...) - for _, key := range keys { - candidate := m.values[key] - if candidate == nil || candidate.removing || candidate.collecting || candidate.preparing || candidate.refs != 0 || candidate.value.Provider != nil && !candidate.repairing || time.Now().Before(candidate.retryAt) { - continue - } - if provider, ok := candidate.value.Provider.(interface{ Quiescent() bool }); ok && !provider.Quiescent() { - continue - } - g = candidate - m.recoveryCursor = key - break - } - } - if g == nil { - m.mu.Unlock() - continue - } - ctx, cancel := context.WithTimeout(m.ctx, 30*time.Minute) - m.preparingCancel = cancel - g.preparing = true - g.refs++ - g.state = "preparing" - g.diagnostic = "" - generation, digest := g.value.Generation, g.value.SpecificationDigest - m.mu.Unlock() - value, err := m.options.Prepare(ctx, generation, digest) - cancel() - m.mu.Lock() - m.preparingCancel = nil - g.preparing = false - g.refs-- - if err == nil && (value.Generation != generation || value.SpecificationDigest != digest || sandbox.ValidateProvider(value.Provider) != nil || value.Probe == nil) { - err = sandbox.ErrOwnership - } - if err == nil { - if g.value.Close != nil { - g.value.Close() - } - g.value = value - g.repairing = false - g.failures = 0 - g.retryAt = time.Time{} - g.state = "preparing" - } else { - if value.Close != nil { - value.Close() - } - g.state = "failed" - g.diagnostic = sandbox.NodeDiagnostic(err) - g.failures++ - delays := []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 30 * time.Minute} - index := g.failures - 1 - if index >= len(delays) { - index = len(delays) - 1 - } - g.retryAt = time.Now().Add(delays[index]) - } - m.mu.Unlock() - } -} - -func (m *GenerationManager) probeLoop() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - for { - select { - case <-m.ctx.Done(): - return - case <-ticker.C: - } - m.mu.Lock() - keys := m.orderedLocked(m.probeCursor) - // Alternate priority probes with the fair retained-provider cursor. A large - // history cannot postpone serving/target qualification or starve old owners. - m.priorityProbe++ - priority := uint64(0) - if m.priorityProbe%2 == 1 { - priority = m.target.Generation - if m.priorityProbe%4 == 3 && m.target.ServingGeneration != nil { - priority = *m.target.ServingGeneration - } - keys = append([]uint64{priority}, keys...) - } - var g *localGeneration - for _, key := range keys { - value := m.values[key] - if value != nil && value.value.Provider != nil && !value.removing && !value.collecting && !value.preparing && !value.repairing { - g = value - if key != priority { - m.probeCursor = key - } - g.refs++ - break - } - } - m.mu.Unlock() - if g == nil { - continue - } - ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second) - err := g.value.Probe(ctx) - cancel() - m.mu.Lock() - g.refs-- - if !errors.Is(err, context.Canceled) { - g.state = "ready" - g.diagnostic = "" - if err != nil { - g.state = "failed" - g.diagnostic = sandbox.NodeDiagnostic(err) - if errors.Is(err, sandbox.ErrRuntimeImageUnavailable) || errors.Is(err, sandbox.ErrMicrosandboxArtifactsUnavailable) { - g.repairing = true - } - } - } - m.mu.Unlock() - } -} - -func (m *GenerationManager) Ready(generation uint64) bool { - m.mu.Lock() - defer m.mu.Unlock() - g := m.values[generation] - return g != nil && !g.removing && g.value.Provider != nil && g.state == "ready" -} diff --git a/services/agents-api/internal/sandbox/node/generations_test.go b/services/agents-api/internal/sandbox/node/generations_test.go deleted file mode 100644 index aeb690d97..000000000 --- a/services/agents-api/internal/sandbox/node/generations_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package node - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - "github.com/google/uuid" -) - -func generationFixture(count int) *GenerationManager { - m := &GenerationManager{values: map[uint64]*localGeneration{}, wake: make(chan struct{}, 1)} - for i := 1; i <= count; i++ { - g := uint64(i) - m.values[g] = &localGeneration{value: GenerationProvider{Generation: g, SpecificationDigest: strings.Repeat("a", 64), Provider: &fakeProvider{}}, state: "ready"} - } - pin := uint64(1) - m.target = sandbox.NodeDeployment{Generation: uint64(count), SpecificationDigest: strings.Repeat("a", 64), ServingGeneration: &pin} - return m -} - -func TestSparseGenerationControlHasNoLifetimeLimit(t *testing.T) { - for _, count := range []int{9, 17, 257} { - t.Run(fmt.Sprint(count), func(t *testing.T) { - m := generationFixture(count) - statuses, retained := map[uint64]bool{}, map[uint64]bool{} - cursor := uint64(0) - for i := 0; i < count; i++ { - batch := m.Statuses() - if len(batch) > 8 || len(batch) < 2 || batch[0].Generation != uint64(count) || batch[1].Generation != 1 { - t.Fatal("unbounded batch or missing priorities", batch) - } - for _, item := range batch { - statuses[item.Generation] = true - } - refs := m.Retained(cursor) - if len(refs) > 8 { - t.Fatal("unbounded retention batch") - } - for _, ref := range refs { - retained[ref.Generation] = true - } - cursor = refs[len(refs)-1].Generation - } - if len(statuses) != count || len(retained) != count || len(m.values) != count { - t.Fatal("sparse rotation lost generations", len(statuses), len(retained), len(m.values)) - } - }) - } -} - -func TestRetentionReplyMustMatchWholePendingExchange(t *testing.T) { - for _, mutation := range []string{"id", "sequence", "connection", "epoch", "partial", "reordered", "digest"} { - t.Run(mutation, func(t *testing.T) { - m := generationFixture(3) - a := &agent{config: AgentConfig{Generations: m}} - c := &agentConnection{id: uuid.NewString(), epoch: 7} - refs := m.Retained(0) - c.pending = &generationControl{ID: uuid.NewString(), Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch, References: refs} - reply := &generationControl{ID: c.pending.ID, Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch} - for _, ref := range refs { - reply.Retentions = append(reply.Retentions, sandbox.GenerationRetention{GenerationReference: ref, Keep: false}) - } - switch mutation { - case "id": - reply.ID = uuid.NewString() - case "sequence": - reply.Sequence++ - case "connection": - reply.ConnectionID = uuid.NewString() - case "epoch": - reply.OwnerEpoch++ - case "partial": - reply.Retentions = reply.Retentions[:1] - case "reordered": - reply.Retentions[0], reply.Retentions[1] = reply.Retentions[1], reply.Retentions[0] - case "digest": - reply.Retentions[0].SpecificationDigest = strings.Repeat("b", 64) - } - work := make(chan []sandbox.GenerationRetention, 1) - f := frame{Control: reply, Deployment: &m.target} - if err := a.acceptRetention(c, f, work); err == nil { - t.Fatal("invalid grant accepted") - } - if len(work) != 0 || len(m.values) != 3 || c.pending == nil { - t.Fatal("invalid exchange authorized partial collection") - } - }) - } - m := generationFixture(3) - a := &agent{config: AgentConfig{Generations: m}} - c := &agentConnection{id: uuid.NewString(), epoch: 7} - refs := m.Retained(0) - c.pending = &generationControl{ID: uuid.NewString(), Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch, References: refs} - reply := &generationControl{ID: c.pending.ID, Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch} - for _, ref := range refs { - reply.Retentions = append(reply.Retentions, sandbox.GenerationRetention{GenerationReference: ref}) - } - work := make(chan []sandbox.GenerationRetention, 1) - f := frame{Control: reply, Deployment: &m.target} - if err := a.acceptRetention(c, f, work); err != nil { - t.Fatal(err) - } - if err := a.acceptRetention(c, f, work); err == nil { - t.Fatal("replayed grant accepted") - } - if len(work) != 1 { - t.Fatal("grant queued more than once") - } -} - -type helperOwnedProvider struct { - *fakeProvider - caller *microsandbox.ProcessCaller -} - -func (p *helperOwnedProvider) Quiescent() bool { return p.caller.Quiescent() } - -func TestGrantedDropWaitsForReferencesAndActualHelperExit(t *testing.T) { - root := t.TempDir() - helper := filepath.Join(root, "helper") - started := filepath.Join(root, "started") - release := filepath.Join(root, "release") - artifact := filepath.Join(root, "runtime") - // A local test helper uses files only as deterministic process barriers. Its - // response waiter returns on cancellation while the actual process stays alive. - script := "#!/bin/sh\ncat >/dev/null\nprintf started > '" + started + "'\nwhile [ ! -f '" + release + "' ]; do sleep 0.01; done\nprintf '{}\\n'\n" - if err := os.WriteFile(helper, []byte(script), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(artifact, []byte("retained bytes"), 0600); err != nil { - t.Fatal(err) - } - caller := µsandbox.ProcessCaller{} - defer func() { _ = os.WriteFile(release, nil, 0600); wait(t, caller.Quiescent) }() - m := generationFixture(3) - m.target.ServingGeneration = nil - m.values[1].value.Provider = &helperOwnedProvider{fakeProvider: &fakeProvider{}, caller: caller} - m.options.Remove = func(context.Context, GenerationProvider) error { return os.Remove(artifact) } - _, _, unref, err := m.Acquire(1) - if err != nil { - t.Fatal(err) - } - grant := sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}} - if err := m.Drop(t.Context(), grant); !errors.Is(err, ErrUnavailable) { - t.Fatal("queued reference did not retain bytes", err) - } - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - _, err := caller.Call(ctx, microsandbox.Request{Config: microsandbox.Config{HelperPath: helper}}) - done <- err - }() - wait(t, func() bool { _, err := os.Stat(started); return err == nil }) - cancel() - if err := <-done; !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - unref() - if caller.Quiescent() { - t.Fatal("caller cancellation pretended helper exited") - } - if err := m.Drop(t.Context(), grant); !errors.Is(err, ErrUnavailable) { - t.Fatal("live helper did not retain generation", err) - } - if _, err := os.Stat(artifact); err != nil { - t.Fatal("granted drop removed live helper bytes", err) - } - if err := os.WriteFile(release, nil, 0600); err != nil { - t.Fatal(err) - } - deadline := time.Now().Add(5 * time.Second) - for !caller.Quiescent() && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } - if !caller.Quiescent() { - t.Fatal("actual helper Wait did not settle") - } - if err := m.Drop(t.Context(), grant); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(artifact); !os.IsNotExist(err) { - t.Fatal("settled unreferenced generation was not collected", err) - } -} - -func TestCanceledConnectionCannotBeginGenerationCollection(t *testing.T) { - m := generationFixture(3) - m.target.ServingGeneration = nil - removed := false - m.options.Remove = func(context.Context, GenerationProvider) error { removed = true; return nil } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - grant := sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}} - if err := m.Drop(ctx, grant); !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if removed || m.values[1] == nil { - t.Fatal("canceled connection began collection") - } -} - -func TestRestartRecoversExactOlderGenerationBeforeAdvertisingReadiness(t *testing.T) { - digest := strings.Repeat("a", 64) - requested := make(chan uint64, 1) - download := make(chan struct{}) - probe := make(chan struct{}, 1) - qualify := make(chan struct{}) - m, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ - Initial: []GenerationProvider{{Generation: 2, SpecificationDigest: digest, Provider: &fakeProvider{}, Probe: func(context.Context) error { return nil }}}, - Recover: []sandbox.GenerationReference{{Generation: 1, SpecificationDigest: digest}}, - Prepare: func(ctx context.Context, generation uint64, got string) (GenerationProvider, error) { - if got != digest { - return GenerationProvider{}, sandbox.ErrOwnership - } - requested <- generation - select { - case <-download: - case <-ctx.Done(): - return GenerationProvider{}, ctx.Err() - } - return GenerationProvider{Generation: generation, SpecificationDigest: got, Provider: &fakeProvider{}, Probe: func(ctx context.Context) error { - select { - case probe <- struct{}{}: - default: - } - select { - case <-qualify: - return nil - case <-ctx.Done(): - return ctx.Err() - } - }}, nil - }, - Remove: func(context.Context, GenerationProvider) error { return nil }, - }) - if err != nil { - t.Fatal(err) - } - defer m.Close() - pin := uint64(2) - m.Deployment(sandbox.NodeDeployment{Generation: 2, SpecificationDigest: digest, ServingGeneration: &pin}) - select { - case generation := <-requested: - if generation != 1 { - t.Fatal("replaced old placement generation", generation) - } - case <-time.After(3 * time.Second): - t.Fatal("old generation was never recovered") - } - if m.Ready(1) { - t.Fatal("missing payload advertised readiness") - } - if err := m.Drop(t.Context(), sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: digest}}); !errors.Is(err, ErrUnavailable) { - t.Fatal("collection entered active repair", err) - } - close(download) - select { - case <-probe: - case <-time.After(4 * time.Second): - t.Fatal("restored provider never qualified") - } - if m.Ready(1) { - t.Fatal("file presence advertised provider readiness") - } - close(qualify) - wait(t, func() bool { return m.Ready(1) }) - provider, ready, release, err := m.Acquire(1) - if err != nil || !ready || provider == nil { - t.Fatal("old generation not usable after actual qualification", err) - } - release() -} - -func TestGenerationDeploymentRejectsConflictingImmutableIdentity(t *testing.T) { - m := generationFixture(3) - bad := m.target - bad.SpecificationDigest = strings.Repeat("b", 64) - if err := m.Deployment(bad); !errors.Is(err, sandbox.ErrOwnership) { - t.Fatal("conflicting target accepted", err) - } - if m.target.SpecificationDigest != strings.Repeat("a", 64) || m.values[3].value.SpecificationDigest != m.target.SpecificationDigest { - t.Fatal("rejected metadata changed provider identity") - } - bad = m.target - future := uint64(4) - bad.ServingGeneration = &future - if err := m.Deployment(bad); !errors.Is(err, sandbox.ErrOwnership) { - t.Fatal("future serving pin accepted", err) - } -} - -func TestInterruptedCollectionNeverPreparesOrServesAfterRestart(t *testing.T) { - digest := strings.Repeat("a", 64) - ref := sandbox.GenerationReference{Generation: 1, SpecificationDigest: digest} - probed := make(chan struct{}, 1) - removes := 0 - manager, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ - Initial: []GenerationProvider{{Generation: 2, SpecificationDigest: digest, Provider: &fakeProvider{}, Probe: func(context.Context) error { - select { - case probed <- struct{}{}: - default: - } - return nil - }}}, - Collect: []sandbox.GenerationReference{ref}, - Prepare: func(context.Context, uint64, string) (GenerationProvider, error) { - t.Error("collection entered preparation") - return GenerationProvider{}, sandbox.ErrInvalid - }, - Remove: func(_ context.Context, value GenerationProvider) error { - removes++ - if value.Generation != 1 { - t.Error("wrong generation") - } - if removes == 1 { - return errors.New("interrupted cleanup") - } - return nil - }, - }) - if err != nil { - t.Fatal(err) - } - defer manager.Close() - pin := uint64(2) - if err = manager.Deployment(sandbox.NodeDeployment{Generation: 2, SpecificationDigest: digest, ServingGeneration: &pin}); err != nil { - t.Fatal(err) - } - select { - case <-probed: - case <-time.After(3 * time.Second): - t.Fatal("provider loop did not run") - } - if _, _, _, err = manager.Acquire(1); err == nil { - t.Fatal("unfinished collection admitted an operation") - } - for _, status := range manager.Statuses() { - if status.Generation == 1 { - t.Fatal("unfinished collection advertised readiness", status) - } - } - if len(manager.Retained(0)) != 2 || removes != 0 { - t.Fatal("restart consumed old drop authorization") - } - cancelled, cancel := context.WithCancel(t.Context()) - cancel() - grant := sandbox.GenerationRetention{GenerationReference: ref} - if err = manager.Drop(cancelled, grant); err == nil || removes != 0 { - t.Fatal("cancelled connection collected") - } - if err = manager.Drop(t.Context(), grant); err == nil { - t.Fatal("expected interrupted cleanup") - } - if _, _, _, err = manager.Acquire(1); err == nil { - t.Fatal("failed cleanup resumed serving") - } - if err = manager.Drop(t.Context(), grant); err != nil { - t.Fatal(err) - } - if len(manager.Retained(0)) != 1 || removes != 2 { - t.Fatal("cleanup did not settle") - } -} - -func TestPreparationDiagnosticPreservesTypedCause(t *testing.T) { - for _, cause := range []error{sandbox.ErrRuntimeDownloadFailed, sandbox.ErrDockerUnavailable, sandbox.ErrKVMUnavailable, sandbox.ErrOwnership, context.Canceled, errors.New("raw secret provider text")} { - t.Run(sandbox.NodeDiagnostic(cause)+cause.Error(), func(t *testing.T) { - m, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ - Prepare: func(context.Context, uint64, string) (GenerationProvider, error) { return GenerationProvider{}, cause }, - Remove: func(context.Context, GenerationProvider) error { return nil }, - }) - if err != nil { - t.Fatal(err) - } - defer m.Close() - if err := m.Deployment(sandbox.NodeDeployment{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}); err != nil { - t.Fatal(err) - } - wait(t, func() bool { s := m.Statuses(); return len(s) == 1 && s[0].State == "failed" }) - if got := m.Statuses()[0].Diagnostic; got != sandbox.NodeDiagnostic(cause) { - t.Fatal("wrong fixed diagnostic", got) - } - }) - } -} diff --git a/services/agents-api/internal/sandbox/node/identity.go b/services/agents-api/internal/sandbox/node/identity.go deleted file mode 100644 index 9b00c852d..000000000 --- a/services/agents-api/internal/sandbox/node/identity.go +++ /dev/null @@ -1,167 +0,0 @@ -package node - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "io" - "net" - "net/url" - "os" - "path/filepath" - "syscall" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" - "github.com/google/uuid" -) - -type StoredIdentity struct { - Identity Identity `json:"identity"` - Credential string `json:"credential"` - CoreURL string `json:"core_url"` - OwnerEpoch uint64 `json:"owner_epoch"` -} - -func lockDirectory(dir string) (func(), error) { - if !filepath.IsAbs(dir) || filepath.Clean(dir) != dir { - return nil, errors.New("node state directory must be canonical and absolute") - } - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - st, err := os.Lstat(dir) - if err != nil || !st.IsDir() || st.Mode().Perm() != 0700 { - return nil, errors.New("node state directory must be private") - } - fd, err := syscall.Open(filepath.Join(dir, "identity.lock"), syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) - if err != nil { - return nil, err - } - if err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - _ = syscall.Close(fd) - return nil, errors.New("sandbox node identity is already in use") - } - return func() { _ = syscall.Flock(fd, syscall.LOCK_UN); _ = syscall.Close(fd) }, nil -} -func readIdentity(dir string) (StoredIdentity, error) { - var out StoredIdentity - fd, err := syscall.Open(filepath.Join(dir, "identity.json"), syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) - if err != nil { - return out, err - } - f := os.NewFile(uintptr(fd), "identity.json") - defer f.Close() - st, err := f.Stat() - if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { - return out, errors.New("node identity must be a private regular file") - } - d := json.NewDecoder(io.LimitReader(f, 16384)) - d.DisallowUnknownFields() - if d.Decode(&out) != nil || d.Decode(new(any)) != io.EOF || !validID(out.Identity.NodeID) || len(out.Credential) != 64 { - return StoredIdentity{}, errors.New("invalid node identity") - } - secret, err := hex.DecodeString(out.Credential) - if err != nil || len(secret) != 32 || hex.EncodeToString(secret) != out.Credential { - return StoredIdentity{}, errors.New("invalid node identity") - } - return out, nil -} -func writeIdentity(dir string, s StoredIdentity) error { - data, err := json.Marshal(s) - if err != nil { - return err - } - f, err := os.CreateTemp(dir, ".identity-*") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err = f.Write(data); err == nil { - err = f.Sync() - } - closeErr := f.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - if err = os.Rename(f.Name(), filepath.Join(dir, "identity.json")); err != nil { - return err - } - d, err := os.Open(dir) - if err != nil { - return err - } - defer d.Close() - return d.Sync() -} - -// InitIdentity creates the credential before any enrollment request. A lost -// enrollment response can therefore be recovered by authenticating this identity. -func InitIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { - release, err := lockDirectory(dir) - if err != nil { - return StoredIdentity{}, err - } - defer release() - return initIdentity(dir, coreURL, identity) -} -func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { - if _, err := endpoint(coreURL, ""); err != nil { - return StoredIdentity{}, err - } - stored, err := readIdentity(dir) - if err == nil { - expected := identity - if expected.NodeID == "" { - expected.NodeID = stored.Identity.NodeID - } - if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL { - return StoredIdentity{}, errors.New("node configuration does not match its retained identity") - } - return stored, nil - } - if !os.IsNotExist(err) { - return StoredIdentity{}, err - } - if identity.NodeID == "" { - identity.NodeID = uuid.NewString() - } - if !validID(identity.NodeID) || !validID(identity.InstallationID) || !providers.IsNode(identity.Provider) || len(identity.BackendFingerprint) != 64 { - return StoredIdentity{}, errors.New("invalid node configuration") - } - secret := make([]byte, 32) - if _, err = rand.Read(secret); err != nil { - return StoredIdentity{}, err - } - stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL} - if err = writeIdentity(dir, stored); err != nil { - return StoredIdentity{}, err - } - return stored, nil -} -func LoadIdentity(dir string) (StoredIdentity, error) { - release, err := lockDirectory(dir) - if err != nil { - return StoredIdentity{}, err - } - defer release() - return readIdentity(dir) -} - -func endpoint(raw, path string) (string, error) { - u, err := url.Parse(raw) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { - return "", errors.New("node Core URL must be an origin") - } - if u.Scheme != "https" { - ip := net.ParseIP(u.Hostname()) - if u.Scheme != "http" || !(u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { - return "", errors.New("remote node Core URL requires HTTPS") - } - } - u.Path = path - return u.String(), nil -} diff --git a/services/agents-api/internal/sandbox/node/observations.go b/services/agents-api/internal/sandbox/node/observations.go deleted file mode 100644 index dc731fc5d..000000000 --- a/services/agents-api/internal/sandbox/node/observations.go +++ /dev/null @@ -1,52 +0,0 @@ -package node - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -var _ runtimeobs.Source = (*provider)(nil) - -func (p *provider) ObservationProviderType() string { return p.kind } - -func observationReference(target runtimeobs.Target) sandbox.Reference { - return sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} -} - -func validObservation(target runtimeobs.Target, reference sandbox.Reference) bool { - return target.Mode == runtimeobs.ModeManaged && validID(target.SessionID) && - validID(target.Instance.ProviderKey) && observationReference(target) == reference && target.TokenUsage == nil -} - -// Observe uses the allocation's existing node resolver and never changes its -// compute state. Session token counters stay in Core, outside provider telemetry. -func (p *provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { - target.TokenUsage = nil - out, err := p.call(ctx, request{Operation: "observe", Reference: observationReference(target), Observation: &target}) - if err != nil { - if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) && - (errors.Is(err, ErrUnavailable) || errors.Is(err, sandbox.ErrComputeUnconfirmed)) { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - return runtimeobs.Sample{}, err - } - if out.Sample == nil { - return runtimeobs.Sample{}, runtimeobs.ErrUnavailable - } - return *out.Sample, nil -} - -func observeProvider(ctx context.Context, provider sandbox.SandboxProvider, target runtimeobs.Target) (runtimeobs.Sample, error) { - if err := providercontract.Require(provider, "Observe"); err != nil { - return runtimeobs.Sample{}, err - } - source, ok := provider.(runtimeobs.Source) - if !ok { - return runtimeobs.Sample{}, providercontract.ErrContract - } - return source.Observe(ctx, target) -} diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go deleted file mode 100644 index c41ab6a2a..000000000 --- a/services/agents-api/internal/sandbox/node/observations_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package node - -import ( - "context" - "encoding/json" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "net/http/httptest" - "reflect" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -type observationProvider struct { - *fakeProvider - observationMu sync.Mutex - targets []runtimeobs.Target - sample runtimeobs.Sample - err error -} - -func (p *observationProvider) Observe(_ context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { - p.observationMu.Lock() - defer p.observationMu.Unlock() - p.targets = append(p.targets, target) - if target.Instance.ComputePhase == "suspended" { - return runtimeobs.Sample{}, runtimeobs.ErrNotRunning - } - return p.sample, p.err -} -func observationTarget(r sandbox.Reference, installation string) runtimeobs.Target { - return runtimeobs.Target{TenantID: r.TenantID, SessionID: uuid.NewString(), EnvironmentID: r.EnvironmentID, Mode: runtimeobs.ModeManaged, - Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: installation, AllocationState: "running", ComputePhase: "running", ProviderState: json.RawMessage(`{"current":{"id":"exact-incarnation","generation":3}}`)}, - TokenUsage: &runtimeobs.TokenUsage{InputTokens: 123, OutputTokens: 456}} -} -func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { - t.Helper() - dir := stateDir(t) - stored, err := InitIdentity(dir, url, id) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: url, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: provider, Probe: func(context.Context) (Health, error) { return Health{}, errors.New("provider not ready for creation") }}) - }() - t.Cleanup(func() { - cancel() - select { - case err := <-done: - if err != nil { - t.Error(err) - } - case <-time.After(5 * time.Second): - t.Error("observation node did not stop") - } - }) - wait(t, func() bool { return hub.Online(id.NodeID) }) - return cancel -} -func TestObservationsRouteThroughAssignedNodeWithoutLifecycleCalls(t *testing.T) { - first, second := identity(), identity() - second.InstallationID = first.InstallationID - hub := NewHub(HubOptions{Authenticate: func(_ context.Context, id, _ string) (Identity, error) { - switch id { - case first.NodeID: - return first, nil - case second.NodeID: - return second, nil - } - return Identity{}, ErrAuthentication - }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) - server := httptest.NewServer(hub) - t.Cleanup(server.Close) - t.Cleanup(hub.Close) - zero, bytes := uint64(0), uint64(1234) - now := time.Now().UTC().Truncate(time.Microsecond) - a := &observationProvider{fakeProvider: &fakeProvider{}, sample: runtimeobs.Sample{ObservedAt: now, MemoryUsageBytes: &zero}} - b := &observationProvider{fakeProvider: &fakeProvider{}, sample: runtimeobs.Sample{ObservedAt: now, MemoryUsageBytes: &bytes}} - runObservationNode(t, hub, server.URL, first, a) - stopSecond := runObservationNode(t, hub, server.URL, second, b) - ra, rb := reference(), reference() - assignments := map[sandbox.Reference]string{ra: first.NodeID, rb: second.NodeID} - source := hub.GenerationProvider("docker", func(_ context.Context, r sandbox.Reference) (string, uint64, error) { - if id, ok := assignments[r]; ok { - return id, 1, nil - } - return "", 0, sandbox.ErrOwnership - }).(runtimeobs.Source) - if typed := source.(interface{ ObservationProviderType() string }).ObservationProviderType(); typed != "docker" { - t.Fatal("provider type lost", typed) - } - for _, test := range []struct { - ref sandbox.Reference - provider *observationProvider - }{{ra, a}, {rb, b}} { - target := observationTarget(test.ref, first.InstallationID) - sample, err := source.Observe(t.Context(), target) - if err != nil || !reflect.DeepEqual(sample, test.provider.sample) { - t.Fatal("observation crossed node or changed sample", sample, err) - } - test.provider.observationMu.Lock() - observed := test.provider.targets[0] - test.provider.observationMu.Unlock() - target.TokenUsage = nil - if !reflect.DeepEqual(observed, target) { - t.Fatal("durable observation target changed", observed, target) - } - target.Instance.ComputePhase = "suspended" - if _, err := source.Observe(t.Context(), target); !errors.Is(err, runtimeobs.ErrNotRunning) { - t.Fatal("suspended observation did not remain stopped", err) - } - } - if _, err := source.Observe(t.Context(), observationTarget(reference(), first.InstallationID)); !errors.Is(err, sandbox.ErrOwnership) { - t.Fatal("unknown allocation was routed", err) - } - for _, p := range []*observationProvider{a, b} { - p.mu.Lock() - if p.creates != 0 || p.kills != 0 || p.reads != 0 { - t.Error("observation invoked lifecycle provider methods") - } - p.mu.Unlock() - } - stopSecond() - wait(t, func() bool { return !hub.Online(second.NodeID) }) - if _, err := source.Observe(t.Context(), observationTarget(rb, first.InstallationID)); !errors.Is(err, runtimeobs.ErrUnavailable) { - t.Fatal("offline source fell back", err) - } -} - -func TestObservationWirePreservesUnavailableAndRejectsMismatchedIdentity(t *testing.T) { - r := reference() - target := observationTarget(r, uuid.NewString()) - target.TokenUsage = nil - q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Operation: "observe", Reference: r, Observation: &target, TimeoutMillis: 1000} - providers := []struct { - name string - provider sandbox.SandboxProvider - want error - }{ - {"unsupported", &fakeProvider{}, providercontract.ErrUnsupported}, - {"unavailable", &observationProvider{fakeProvider: &fakeProvider{}, err: runtimeobs.ErrUnavailable}, runtimeobs.ErrUnavailable}, - {"stopped", &observationProvider{fakeProvider: &fakeProvider{}, err: runtimeobs.ErrNotRunning}, runtimeobs.ErrNotRunning}, - {"ownership", &observationProvider{fakeProvider: &fakeProvider{}, err: sandbox.ErrOwnership}, sandbox.ErrOwnership}, - } - for _, test := range providers { - t.Run(test.name, func(t *testing.T) { - out := execute(t.Context(), test.provider, q) - if !errors.Is(responseError(out), test.want) || out.Sample != nil { - t.Fatal("observation error or missing sample was fabricated", out) - } - }) - } - p := &observationProvider{fakeProvider: &fakeProvider{}} - for _, mutate := range []func(*runtimeobs.Target){func(t *runtimeobs.Target) { t.EnvironmentID = uuid.NewString() }, func(t *runtimeobs.Target) { t.Instance.AllocationID = uuid.NewString() }, func(t *runtimeobs.Target) { t.TenantID = uuid.NewString() }, func(t *runtimeobs.Target) { t.TokenUsage = &runtimeobs.TokenUsage{} }, func(t *runtimeobs.Target) { t.Mode = runtimeobs.ModeSelfHosted }} { - invalid := target - mutate(&invalid) - q.Observation = &invalid - if out := execute(t.Context(), p, q); !errors.Is(responseError(out), sandbox.ErrInvalid) { - t.Fatal("mismatched observation reached provider", out) - } - } - if len(p.targets) != 0 { - t.Fatal("invalid target was observed") - } -} diff --git a/services/agents-api/internal/sandbox/node/operations_test.go b/services/agents-api/internal/sandbox/node/operations_test.go deleted file mode 100644 index 67bbd6292..000000000 --- a/services/agents-api/internal/sandbox/node/operations_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package node - -import ( - "context" - "encoding/json" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" - "testing" -) - -func TestUnsupportedWireIsExplicitAndDoesNotInvokeProvider(t *testing.T) { - p := &fakeProvider{} - q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Reference: reference(), TimeoutMillis: 1000, Operation: "initial"} - out := execute(t.Context(), p, q) - if p.creates != 0 || p.reads != 0 || p.kills != 0 { - t.Fatal("unsupported operation reached provider") - } - raw, err := json.Marshal(out) - if err != nil { - t.Fatal(err) - } - var decoded response - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - if reason, ok := providercontract.UnsupportedReason(responseError(decoded), "Initial"); !ok || reason != "fixture_operation_not_supported" { - t.Fatal(decoded) - } - for _, bad := range []response{{ErrorCode: "unsupported"}, {ErrorCode: "unsupported", Unsupported: &providercontract.UnsupportedError{Operation: "Initial", Reason: "private / credential"}}, {Unsupported: out.Unsupported}} { - if !errors.Is(responseError(bad), sandbox.ErrComputeUnconfirmed) { - t.Fatal("malformed failure became certainty", bad) - } - } -} -func TestUnsupportedProxyRejectsBeforeNodeResolution(t *testing.T) { - p := &provider{kind: "docker", resolveGeneration: func(context.Context, sandbox.Reference) (string, uint64, error) { - t.Fatal("unsupported call resolved a node") - return "", 0, nil - }} - if err := sandbox.ValidateProvider(p); err != nil { - t.Fatal(err) - } - if _, err := p.Initial(t.Context(), reference()); !errors.Is(err, providercontract.ErrUnsupported) { - t.Fatal(err) - } - if _, err := p.ObserveBatch(t.Context(), nil); !errors.Is(err, providercontract.ErrUnsupported) { - t.Fatal(err) - } -} -func TestNodeOperationMappingCoversForwardedMethods(t *testing.T) { - for _, method := range []string{"Create", "GetInfo", "Renew", "Kill", "RunCommand", "Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute", "Observe"} { - if wire := operationWire(method); wire == "" || operationMethod(wire) != method { - t.Fatal(method) - } - } -} diff --git a/services/agents-api/internal/sandbox/node/provider_operations_fixture_test.go b/services/agents-api/internal/sandbox/node/provider_operations_fixture_test.go deleted file mode 100644 index 5a8b8d579..000000000 --- a/services/agents-api/internal/sandbox/node/provider_operations_fixture_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package node - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func (*fakeProvider) ProviderOperations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - } -} -func (*fakeProvider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { - return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { - return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} -} -func (*fakeProvider) VerifyCredential(context.Context, []sandbox.Reference) error { - return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} -} -func (*observationProvider) ProviderOperations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Observe": {State: providercontract.Supported}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - } -} diff --git a/services/agents-api/internal/sandbox/node/recovery_test.go b/services/agents-api/internal/sandbox/node/recovery_test.go deleted file mode 100644 index aa7db298b..000000000 --- a/services/agents-api/internal/sandbox/node/recovery_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package node - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" - "github.com/gorilla/websocket" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) { - id := identity() - var credential string - options := func(epoch uint64) HubOptions { - return HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return epoch, nil }} - } - first := NewHub(options(4)) - second := NewHub(options(5)) - defer first.Close() - defer second.Close() - var mu sync.Mutex - current := first - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock(); h := current; mu.Unlock(); h.ServeHTTP(w, r) })) - defer server.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - credential = stored.Credential - start := func() (context.CancelFunc, chan error) { - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: credential, Provider: &fakeProvider{}, Probe: probe}) - }() - return cancel, done - } - stop, done := start() - defer func() { stop() }() - wait(t, func() bool { return first.Online(id.NodeID) }) - mu.Lock() - current = second - mu.Unlock() - first.Close() - wait(t, func() bool { return second.Online(id.NodeID) }) - if first.Online(id.NodeID) { - t.Fatal("old owner remained online") - } - if _, err = first.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { - t.Fatalf("old owner request = %v", err) - } - if _, err = second.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); err != nil { - t.Fatal(err) - } - stop() - if err = <-done; err != nil { - t.Fatal(err) - } - wait(t, func() bool { return !second.Online(id.NodeID) }) - persisted, err := LoadIdentity(dir) - if err != nil || persisted.OwnerEpoch != 5 || persisted.Credential != credential { - t.Fatal("restart identity changed") - } - stop, done = start() - wait(t, func() bool { return second.Online(id.NodeID) }) - stop() - if err = <-done; err != nil { - t.Fatal(err) - } -} - -func TestAgentRejectsOwnerEpochRollback(t *testing.T) { - id := identity() - upgrade := websocket.Upgrader{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, e := upgrade.Upgrade(w, r, nil) - if e != nil { - return - } - defer conn.Close() - _, _ = readFrame(conn) - _ = writeFrame(conn, frame{Type: "welcome", ConnectionID: uuid.NewString(), OwnerEpoch: 3}) - _, _ = readFrame(conn) - })) - defer server.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - stored.OwnerEpoch = 4 - if err = writeIdentity(dir, stored); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - err = Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: &fakeProvider{}, Probe: probe}) - if !errors.Is(err, sandbox.ErrOwnership) { - t.Fatalf("stale owner = %v", err) - } -} - -func TestHeartbeatAcknowledgementKeepsIdleConnectionAlive(t *testing.T) { - id := identity() - beats := make(chan Health, 1) - var heartbeats int - hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Heartbeat: func(ctx context.Context, id Identity, connection string, epoch uint64, h Health) error { - heartbeats++ - if heartbeats == 1 { - return nil - } - select { - case beats <- h: - default: - } - return nil - }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: &fakeProvider{}, Probe: probe}) - }() - defer func() { - cancel() - if err := <-done; err != nil { - t.Error(err) - } - }() - select { - case h := <-beats: - if !h.ProviderReady || h.ObservedAt.IsZero() { - t.Fatal("invalid heartbeat") - } - case <-time.After(12 * time.Second): - t.Fatal("missing heartbeat") - } - if !hub.Online(id.NodeID) { - t.Fatal("idle node disconnected") - } - if _, err = hub.Proxy(id.NodeID, "docker", 1).GetInfo(ctx, reference()); err != nil { - t.Fatal(err) - } -} - -func TestHubExpiresSilentNode(t *testing.T) { - if testing.Short() { - t.Skip("real heartbeat timeout") - } - id := identity() - hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - url := "ws" + strings.TrimPrefix(server.URL, "http") + "?node_id=" + id.NodeID - conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": []string{"Bearer test"}}) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { - t.Fatal(err) - } - if _, err = readFrame(conn); err != nil { - t.Fatal(err) - } - wait(t, func() bool { return hub.Online(id.NodeID) }) - assertDuplicateRejected(t, server.URL, id.NodeID, "test") - _ = conn.SetReadDeadline(time.Now().Add(38 * time.Second)) - if _, err = readFrame(conn); err == nil { - t.Fatal("silent node connection survived deadline") - } - wait(t, func() bool { return !hub.Online(id.NodeID) }) - wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) - replacement := connectRawNode(t, server.URL, id) - defer replacement.Close() - wait(t, func() bool { return hub.Online(id.NodeID) }) -} - -func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { - id := identity() - health := make(chan Health, 1) - hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Heartbeat: func(ctx context.Context, id Identity, connection string, epoch uint64, h Health) error { - select { - case health <- h: - default: - } - return nil - }}) - server := httptest.NewServer(hub) - defer server.Close() - defer hub.Close() - dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) - if err != nil { - t.Fatal(err) - } - p := &fakeProvider{} - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: p, Probe: func(context.Context) (Health, error) { return Health{}, errors.New("private backend detail") }}) - }() - defer func() { - cancel() - if err := <-done; err != nil { - t.Error(err) - } - }() - wait(t, func() bool { return hub.Online(id.NodeID) }) - select { - case h := <-health: - if h.ProviderReady || h.Diagnostic != "provider_unavailable" { - t.Fatalf("unsafe health: %+v", h) - } - case <-time.After(time.Second): - t.Fatal("missing health") - } - proxy := hub.Proxy(id.NodeID, "docker", 1) - r := reference() - if _, err = proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { - t.Fatalf("create = %v", err) - } - if _, err = proxy.GetInfo(ctx, r); err != nil { - t.Fatal(err) - } - if err = proxy.Kill(ctx, r); err != nil { - t.Fatal(err) - } - p.mu.Lock() - defer p.mu.Unlock() - if p.creates != 0 || p.kills != 1 || p.reads != 1 { - t.Fatal("degraded node incorrectly dispatched work") - } -} - -func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { - id := identity() - dir := stateDir(t) - stored, err := InitIdentity(dir, "https://core.example.test", id) - if err != nil { - t.Fatal(err) - } - mismatch := id - mismatch.BackendFingerprint = strings.Repeat("2", 64) - if _, err = InitIdentity(dir, stored.CoreURL, mismatch); err == nil { - t.Fatal("adopted wrong backend") - } - original, err := LoadIdentity(dir) - if err != nil || original.Credential != stored.Credential { - t.Fatal("credential rotated") - } - if err = os.WriteFile(filepath.Join(dir, "identity.json"), []byte("corrupt"), 0600); err != nil { - t.Fatal(err) - } - if _, err = InitIdentity(dir, stored.CoreURL, id); err == nil { - t.Fatal("corrupt identity replaced") - } - if err = os.Remove(filepath.Join(dir, "identity.json")); err != nil { - t.Fatal(err) - } - if _, err = LoadIdentity(dir); err == nil { - t.Fatal("missing identity recreated by load") - } -} diff --git a/services/agents-api/internal/sandbox/operations.go b/services/agents-api/internal/sandbox/operations.go deleted file mode 100644 index 6c781cb62..000000000 --- a/services/agents-api/internal/sandbox/operations.go +++ /dev/null @@ -1,80 +0,0 @@ -package sandbox - -import ( - "errors" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "reflect" -) - -// These existing interfaces are the canonical operation inventory. Declarations -// must cover every method, including explicit unsupported implementations. -var providerInterfaces = []reflect.Type{ - reflect.TypeFor[SandboxProvider](), reflect.TypeFor[CheckpointProvider](), - reflect.TypeFor[SelectionDiscoverer](), reflect.TypeFor[CredentialVerifier](), - reflect.TypeFor[runtimeobs.Source](), reflect.TypeFor[runtimeobs.BatchSource](), -} - -func ValidateProvider(p SandboxProvider) error { - if err := providercontract.Validate(p, providerInterfaces...); err != nil { - return err - } - return ValidateOperations(p.ProviderOperations()) -} - -// ValidateOperations rejects omitted, unknown and contradictory declarations. -func ValidateOperations(operations providercontract.Operations) error { - known := map[string]bool{} - for _, contract := range providerInterfaces { - for i := 0; i < contract.NumMethod(); i++ { - name := contract.Method(i).Name - if name != "ProviderOperations" { - known[name] = true - if err := operations[name].Check(name); err != nil && !errors.Is(err, providercontract.ErrUnsupported) { - return err - } - } - } - } - for name := range operations { - if !known[name] { - return fmt.Errorf("%w: unknown operation %s", providercontract.ErrContract, name) - } - } - required := reflect.TypeFor[SandboxProvider]() - for i := 0; i < required.NumMethod(); i++ { - name := required.Method(i).Name - if name != "ProviderOperations" && operations[name].State != providercontract.Supported { - return fmt.Errorf("%w: required operation %s", providercontract.ErrContract, name) - } - } - // The checkpoint lifecycle is indivisible: partial cleanup or restore support - // cannot safely own a compute incarnation. - checkpoint := reflect.TypeFor[CheckpointProvider]() - for i := 0; i < checkpoint.NumMethod(); i++ { - name := checkpoint.Method(i).Name - if _, required := reflect.TypeFor[SandboxProvider]().MethodByName(name); !required && operations[name].State != operations["Initial"].State { - return fmt.Errorf("%w: incomplete checkpoint lifecycle", providercontract.ErrContract) - } - } - if operations["ObserveBatch"].State == providercontract.Supported && operations["Observe"].State != providercontract.Supported { - return fmt.Errorf("%w: batch observation requires observation", providercontract.ErrContract) - } - return nil -} - -func SupportsCheckpoint(p SandboxProvider) bool { - return providercontract.Require(p, "Initial") == nil -} - -func Checkpoint(p SandboxProvider) (CheckpointProvider, error) { - if err := providercontract.Require(p, "Initial"); err != nil { - return nil, err - } - cp, ok := p.(CheckpointProvider) - if !ok { - return nil, providercontract.ErrContract - } - return cp, nil -} diff --git a/services/agents-api/internal/sandbox/operations_test.go b/services/agents-api/internal/sandbox/operations_test.go deleted file mode 100644 index 93cb1320a..000000000 --- a/services/agents-api/internal/sandbox/operations_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package sandbox_test - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - "reflect" - "testing" -) - -type changedDeclaration struct { - *docker.Provider - operations providercontract.Operations -} - -func (p *changedDeclaration) ProviderOperations() providercontract.Operations { return p.operations } - -type onlyRequired struct{ sandbox.SandboxProvider } - -func (*onlyRequired) ProviderOperations() providercontract.Operations { return docker.Operations() } - -func TestDeclarationsRejectMissingUnknownAndContradictoryOperations(t *testing.T) { - for _, mutate := range []struct { - name string - change func(providercontract.Operations) - }{ - {"omitted", func(o providercontract.Operations) { delete(o, "DeleteSnapshot") }}, - {"zero", func(o providercontract.Operations) { o["ObserveBatch"] = providercontract.Support{} }}, - {"unknown", func(o providercontract.Operations) { - o["FutureOperation"] = providercontract.Support{State: providercontract.Supported} - }}, - {"required unsupported", func(o providercontract.Operations) { - o["Renew"] = providercontract.Support{State: providercontract.Unsupported, Reason: "no_native_lease"} - }}, - {"partial checkpoint", func(o providercontract.Operations) { - o["Initial"] = providercontract.Support{State: providercontract.Supported} - }}, - {"unsafe reason", func(o providercontract.Operations) { - o["ObserveBatch"] = providercontract.Support{State: providercontract.Unsupported, Reason: "https://private:key@host"} - }}, - {"unknown state", func(o providercontract.Operations) { - o["ObserveBatch"] = providercontract.Support{State: "unavailable"} - }}, - } { - t.Run(mutate.name, func(t *testing.T) { - o := docker.Operations() - mutate.change(o) - if err := sandbox.ValidateProvider(&changedDeclaration{Provider: &docker.Provider{}, operations: o}); !errors.Is(err, providercontract.ErrContract) { - t.Fatal(err) - } - }) - } - if err := sandbox.ValidateProvider(&onlyRequired{}); !errors.Is(err, providercontract.ErrContract) { - t.Fatal("missing extension implementations accepted", err) - } - var nilProvider *docker.Provider - if err := sandbox.ValidateProvider(nilProvider); !errors.Is(err, providercontract.ErrContract) { - t.Fatal("typed nil accepted", err) - } -} - -// Unsupported methods must be callable safely even without native clients or -// configuration: any attempt at native I/O would panic on these zero providers. -func TestEveryUnsupportedNativeOperationRejectsWithoutSideEffects(t *testing.T) { - for _, provider := range []sandbox.SandboxProvider{&docker.Provider{}, &e2b.Provider{}, µsandbox.Provider{}} { - if err := sandbox.ValidateProvider(provider); err != nil { - t.Fatal(err) - } - for name, support := range provider.ProviderOperations() { - if support.State != providercontract.Unsupported { - continue - } - method := reflect.ValueOf(provider).MethodByName(name) - arguments := make([]reflect.Value, method.Type().NumIn()) - for i := range arguments { - arguments[i] = reflect.Zero(method.Type().In(i)) - } - arguments[0] = reflect.ValueOf(context.Background()) - values := method.Call(arguments) - err, _ := values[len(values)-1].Interface().(error) - reason, ok := providercontract.UnsupportedReason(err, name) - if !ok || reason != support.Reason { - t.Fatalf("%T.%s returned %v", provider, name, err) - } - for _, v := range values[:len(values)-1] { - if !v.IsZero() { - t.Fatalf("%s fabricated a successful value", name) - } - } - } - } -} - -// An extended interface cannot inherit success through the existing declaration. -type nextContract interface { - sandbox.SandboxProvider - NextOperation(context.Context) error -} -type futureProvider struct{ *docker.Provider } - -func (*futureProvider) NextOperation(context.Context) error { return nil } -func TestNewContractRequiresAnAuthoredDecision(t *testing.T) { - for _, p := range []providercontract.Declared{&docker.Provider{}, &futureProvider{&docker.Provider{}}} { - if err := providercontract.Validate(p, reflect.TypeFor[nextContract]()); !errors.Is(err, providercontract.ErrContract) { - t.Fatal("new operation inherited a default", err) - } - } -} diff --git a/services/agents-api/internal/sandbox/providers/capacity_other.go b/services/agents-api/internal/sandbox/providers/capacity_other.go deleted file mode 100644 index 33190f1c6..000000000 --- a/services/agents-api/internal/sandbox/providers/capacity_other.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !linux - -package providers - -import ( - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func hostCapacity(sandbox.Resources) error { return errors.New("sandbox nodes require Linux") } diff --git a/services/agents-api/internal/sandbox/providers/config.go b/services/agents-api/internal/sandbox/providers/config.go deleted file mode 100644 index 8e71e4a46..000000000 --- a/services/agents-api/internal/sandbox/providers/config.go +++ /dev/null @@ -1,96 +0,0 @@ -// Node-local adapter configuration and construction. -package providers - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "os" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -type Config struct { - Specification sandbox.DeploymentSpec `json:"specification"` - Generation uint64 `json:"generation"` - CoreURL string `json:"core_url"` - Provider string `json:"provider"` - InstallationID string `json:"installation_id"` - Docker *Docker `json:"docker,omitempty"` - Microsandbox *Microsandbox `json:"microsandbox,omitempty"` -} - -type Docker struct { - Host string `json:"host"` - Image string `json:"image"` - Network string `json:"network"` - SeccompFile string `json:"seccomp_file"` - ExtraHosts []string `json:"extra_hosts"` - NestedSandbox bool `json:"nested_sandbox"` -} - -// Load rejects unknown fields, mixed adapters and explicit null configuration. -func Load(file string) (Config, error) { - var config Config - raw, err := os.ReadFile(file) - if err != nil { - return config, errors.New("cannot read sandbox configuration") - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { - return config, errors.New("invalid sandbox configuration") - } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return config, errors.New("invalid sandbox configuration") - } - _, docker := fields["docker"] - _, micro := fields["microsandbox"] - if docker && micro { - return config, errors.New("only one sandbox provider can be configured") - } - return config, nil -} - -type Built struct { - SpecificationDigest string - Provider sandbox.SandboxProvider - InstallationID, BackendFingerprint string - Probe func(context.Context) error -} - -func Build(config Config) (*Built, func(), error) { - closeProvider := func() {} - if err := validateSpecification(config); err != nil { - return nil, closeProvider, err - } - id, err := uuid.Parse(config.InstallationID) - if err != nil || id == uuid.Nil || id.String() != config.InstallationID { - return nil, closeProvider, errors.New("sandbox requires a canonical installation_id UUID") - } - adapter, err := Lookup(config.Provider) - if err != nil || adapter.BuildLocal == nil { - return nil, closeProvider, errors.New("sandbox provider is not node-local") - } - result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} - closeProvider, err = adapter.BuildLocal(config, result) - if err != nil { - return nil, closeProvider, err - } - if err := ValidateBinding(adapter, result.Provider); err != nil { - closeProvider() - return nil, func() {}, err - } - return result, closeProvider, nil -} - -func BackendFingerprint(kind, namespace string) string { - digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) - return hex.EncodeToString(digest[:]) -} diff --git a/services/agents-api/internal/sandbox/providers/operations.go b/services/agents-api/internal/sandbox/providers/operations.go deleted file mode 100644 index b6c619c5d..000000000 --- a/services/agents-api/internal/sandbox/providers/operations.go +++ /dev/null @@ -1,18 +0,0 @@ -package providers - -import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "reflect" -) - -// ValidateBinding catches construction that disagrees with its registration. -func ValidateBinding(adapter Adapter, provider sandbox.SandboxProvider) error { - if err := sandbox.ValidateProvider(provider); err != nil { - return err - } - if adapter.Operations == nil || !reflect.DeepEqual(adapter.Operations(), provider.ProviderOperations()) { - return providercontract.ErrContract - } - return nil -} diff --git a/services/agents-api/internal/sandbox/providers/operations_test.go b/services/agents-api/internal/sandbox/providers/operations_test.go deleted file mode 100644 index ad305f48a..000000000 --- a/services/agents-api/internal/sandbox/providers/operations_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package providers - -import ( - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "testing" -) - -func TestRegistrationRejectsMissingAndMismatchedDeclarations(t *testing.T) { - for _, adapter := range []Adapter{{}, {Operations: func() providercontract.Operations { return nil }}} { - adapters["invalid-contract-fixture"] = adapter - if _, err := Lookup("invalid-contract-fixture"); err == nil { - t.Fatal("invalid declaration registered") - } - } - delete(adapters, "invalid-contract-fixture") - if err := ValidateBinding(Adapter{Operations: e2b.Operations}, &docker.Provider{}); !errors.Is(err, providercontract.ErrContract) { - t.Fatal("registration differs from instance", err) - } -} diff --git a/services/agents-api/internal/sandbox/providers/probe.go b/services/agents-api/internal/sandbox/providers/probe.go deleted file mode 100644 index c9ec52934..000000000 --- a/services/agents-api/internal/sandbox/providers/probe.go +++ /dev/null @@ -1,113 +0,0 @@ -package providers - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "runtime" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/containerd/errdefs" - "github.com/moby/moby/client" -) - -// Probes report the first failed check. Precedence runs from the provider -// platform (Docker daemon or KVM), through Docker limit support and host -// capacity for one sandbox of the deployment specification, to the installed -// Runtime content (image or microsandbox artifacts). Unclassified failures stay -// provider_unavailable. The returned text is local; only its code is reported. - -// kvmDevice is replaceable only by tests. -var kvmDevice = "/dev/kvm" - -func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { - return func(ctx context.Context) error { - if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { - return sandbox.ErrDockerUnavailable - } - host, err := c.Info(ctx, client.InfoOptions{}) - if err != nil { - return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrDockerUnavailable) - } - if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { - return sandbox.ErrDockerLimitsUnsupported - } - if host.Info.MemTotal <= 0 { - return errors.New("Docker host memory capacity is unavailable") - } - if err := checkCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { - return err - } - if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { - return sandbox.ErrRuntimeImageUnavailable - } else if err != nil { - // The daemon did not answer; the image may still be present. - return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrDockerUnavailable) - } - return nil - } -} - -// A successful Runtime integrity check is cached for this immutable -// configuration. A failure is checked again on the next heartbeat, so repaired -// artifacts recover without a restart. Lifecycle calls still verify the exact -// artifact themselves. -func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(context.Context) error { - var integrity sync.Mutex - verified := false - return func(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - if runtime.GOOS != "linux" { - return fmt.Errorf("%w: microsandbox requires a Linux KVM node", sandbox.ErrKVMUnavailable) - } - kvm, err := os.OpenFile(kvmDevice, os.O_RDWR, 0) - if err != nil { - return sandbox.ErrKVMUnavailable - } - _ = kvm.Close() - if err := hostCapacity(resources); err != nil { - return err - } - integrity.Lock() - if !verified { - if err := verifyMicrosandboxArtifacts(entry); err != nil { - integrity.Unlock() - return err - } - verified = true - } - integrity.Unlock() - helper, err := os.Stat(entry.HelperPath) - if err != nil || !helper.Mode().IsRegular() || helper.Mode().Perm()&0111 == 0 { - return fmt.Errorf("%w: microsandbox helper is unavailable", sandbox.ErrMicrosandboxArtifactsUnavailable) - } - home, err := os.Lstat(entry.RuntimeHome) - if err != nil || !home.IsDir() || home.Mode().Perm() != 0700 { - return errors.New("microsandbox state directory is unavailable") - } - return nil - } -} - -func verifyMicrosandboxArtifacts(entry Microsandbox) error { - for _, artifact := range []struct{ path, hash string }{{entry.RuntimePath, entry.RuntimeSHA256}, {entry.FirmwarePath, entry.FirmwareSHA256}} { - f, err := os.Open(artifact.path) - if err != nil { - return sandbox.ErrMicrosandboxArtifactsUnavailable - } - h := sha256.New() - _, err = io.Copy(h, f) - _ = f.Close() - if err != nil || hex.EncodeToString(h.Sum(nil)) != artifact.hash { - return fmt.Errorf("%w: artifact integrity check failed", sandbox.ErrMicrosandboxArtifactsUnavailable) - } - } - return nil -} diff --git a/services/agents-api/internal/sandbox/providers/registry.go b/services/agents-api/internal/sandbox/providers/registry.go deleted file mode 100644 index 3591fc145..000000000 --- a/services/agents-api/internal/sandbox/providers/registry.go +++ /dev/null @@ -1,177 +0,0 @@ -// Package providers is the explicit registration boundary for sandbox adapters. -// It performs read-only configuration work; it never allocates compute. -package providers - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" -) - -// Adapter describes configuration and transport independently of compute operations. -// Native operation support comes from the adapter-owned complete declaration. -type Adapter struct { - Policy sandbox.DeploymentPolicy - ReplaceCredential func(sandbox.Selection, sandbox.Selection) sandbox.Selection - CredentialRequiresReset func(error) bool - CredentialUnconfirmed error - Restore func(sandbox.Selection) (sandbox.Selection, error) - ResolveChange func(sandbox.Selection, sandbox.Selection) sandbox.Selection - BuildLocal func(Config, *Built) (func(), error) - BuildDirect func(DirectConfig) (sandbox.SandboxProvider, error) - Credential bool - PublicOrigin bool - Mode string - Operations func() providercontract.Operations - IdleSeconds, RetentionSeconds int64 - ValidateSpecification func(sandbox.DeploymentSpec) error - ValidateResources func(sandbox.Resources) error - Normalize func(sandbox.Selection) (sandbox.Selection, error) -} - -var adapters = map[string]Adapter{ - "docker": { - Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, - ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, - Normalize: nodeSelection(docker.ValidateSpecification), - }, - "microsandbox": { - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, - IdleSeconds: 300, RetentionSeconds: 86400, - ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, - Normalize: nodeSelection(microsandbox.ValidateSpecification), - }, - "e2b": { - Policy: e2b.Policy(), Operations: e2b.Operations, Mode: "direct", BuildDirect: buildE2B, - Credential: true, PublicOrigin: true, - ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, - CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, - ResolveChange: e2b.ResolveChange, Normalize: e2b.NormalizeSelection, - ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, - }, -} - -func Lookup(kind string) (Adapter, error) { - a, ok := adapters[kind] - if !ok || a.Operations == nil { - return Adapter{}, fmt.Errorf("%w: unsupported sandbox provider", sandbox.ErrInvalid) - } - if err := sandbox.ValidateOperations(a.Operations()); err != nil { - return Adapter{}, err - } - return a, nil -} -func IsNode(kind string) bool { a, e := Lookup(kind); return e == nil && a.Mode == "nodes" } -func SupportsCheckpoint(kind string) bool { - a, e := Lookup(kind) - return e == nil && a.Operations()["Initial"].State == providercontract.Supported -} - -// RetainedLimit keeps nodes without checkpoint support within their active capacity. -func RetainedLimit(kind string, active, retained int) int { - a, err := Lookup(kind) - if err == nil && a.Mode == "nodes" && !SupportsCheckpoint(kind) { - return active - } - return retained -} - -func ValidateSpecification(kind string, s sandbox.DeploymentSpec) error { - a, e := Lookup(kind) - if e != nil { - return e - } - return a.ValidateSpecification(s) -} -func ValidateResources(kind string, s sandbox.Resources) error { - a, e := Lookup(kind) - if e != nil { - return e - } - return a.ValidateResources(s) -} -func Normalize(s sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(s.Provider) - if e != nil { - return s, e - } - return a.Normalize(s) -} -func nodeSelection(validate func(sandbox.DeploymentSpec) error) func(sandbox.Selection) (sandbox.Selection, error) { - return func(s sandbox.Selection) (sandbox.Selection, error) { - if s.E2B != nil { - return s, sandbox.ErrInvalid - } - return s, validate(s.DeploymentSpec) - } -} - -// Description is derived once for both preview and persistence. Its fingerprint -// identifies a namespace, never mutable capacity or a credential. -type Description struct { - Mode, BackendFingerprint string - IdleSeconds, RetentionSeconds int64 -} - -func Describe(kind, installation string) (Description, error) { - a, e := Lookup(kind) - if e != nil { - return Description{}, e - } - namespace := a.Mode - if a.Mode == "direct" { - namespace = kind - } - digest := sha256.Sum256([]byte(kind + "\x00" + namespace + ":" + installation)) - return Description{a.Mode, hex.EncodeToString(digest[:]), a.IdleSeconds, a.RetentionSeconds}, nil -} - -func UsesCredential(kind string) bool { a, e := Lookup(kind); return e == nil && a.Credential } -func Restore(s sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(s.Provider) - if e != nil { - return s, e - } - if a.Restore != nil { - return a.Restore(s) - } - s.E2B = nil - return s, nil -} -func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(next.Provider) - if e != nil { - return next, e - } - if a.ResolveChange != nil { - next = a.ResolveChange(next, previous) - } - return Normalize(next) -} - -func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { - a, e := Lookup(owner.Provider) - if e != nil { - return owner, e - } - if a.ReplaceCredential == nil { - return owner, sandbox.ErrInvalid - } - return a.ReplaceCredential(owner, candidate), nil -} - -// PythonDeploymentContract projects the same registered adapter policies into -// the node installer; no second provider list exists in another language. -func PythonDeploymentContract() string { - policies := make(map[string]sandbox.DeploymentPolicy, len(adapters)) - for kind, a := range adapters { - policies[kind] = a.Policy - } - return sandbox.PythonDeploymentContract(policies) -} diff --git a/services/agents-api/internal/sandbox/providers/registry_test.go b/services/agents-api/internal/sandbox/providers/registry_test.go deleted file mode 100644 index 51b99954b..000000000 --- a/services/agents-api/internal/sandbox/providers/registry_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package providers - -import ( - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { - installation := uuid.NewString() - for _, tc := range []struct { - kind, mode, namespace string - idle, retention int64 - checkpoint bool - }{ - {"docker", "nodes", "nodes", 0, 0, false}, - {"microsandbox", "nodes", "nodes", 300, 86400, true}, - {"e2b", "direct", "e2b", 0, 0, false}, - } { - t.Run(tc.kind, func(t *testing.T) { - d, err := Describe(tc.kind, installation) - if err != nil || d.Mode != tc.mode || d.IdleSeconds != tc.idle || d.RetentionSeconds != tc.retention || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { - t.Fatalf("wrong namespace or defaults: %+v %v", d, err) - } - a, err := Lookup(tc.kind) - if err != nil || SupportsCheckpoint(tc.kind) != tc.checkpoint || IsNode(tc.kind) != (tc.mode == "nodes") || (a.BuildLocal != nil) != (tc.mode == "nodes") || (a.BuildDirect != nil) != (tc.mode == "direct") { - t.Fatal("inconsistent construction/capability registration", err) - } - }) - } - if _, err := Describe("unregistered", installation); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("unknown kind accepted", err) - } -} - -func TestSelectionNormalizationAndCredentialInheritance(t *testing.T) { - input := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "original-key", Template: "runtime:" + uuid.NewString()}} - normalized, err := Normalize(input) - if err != nil || normalized.E2B.APIURL != "https://api.e2b.app" || normalized.E2B.Domain != "e2b.app" { - t.Fatal("defaults not normalized", err) - } - if input.E2B.APIURL != "" || input.E2B.Domain != "" { - t.Fatal("normalization changed request") - } - normalized.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} - request := input - request.E2B = &sandbox.E2BConfiguration{Template: input.E2B.Template} - next, err := ResolveChange(request, normalized) - if err != nil || next.Resources != normalized.Resources || next.E2B.APIKey != "original-key" || next.E2B.APIURL != normalized.E2B.APIURL || next.ReplacesCredential() { - t.Fatal("omitted values lost committed selection", err) - } - request.E2B.ReplaceCredential = true - if _, err := ResolveChange(request, normalized); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("explicit empty credential silently inherited", err) - } - if request.E2B.APIKey != "" || request.Resources != (sandbox.Resources{}) { - t.Fatal("resolution changed request") - } -} - -func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { - const kind = "contract-test-provider" - // Registration is test-local: production registrations are fixed, never plugins. - adapters[kind] = Adapter{Mode: "nodes", Operations: docker.Operations, ValidateSpecification: func(sandbox.DeploymentSpec) error { return nil }, Normalize: nodeSelection(func(sandbox.DeploymentSpec) error { return nil })} - defer delete(adapters, kind) - s, err := Normalize(sandbox.Selection{Provider: kind}) - if err != nil || s.Provider != kind || !IsNode(kind) || SupportsCheckpoint(kind) { - t.Fatal("new entry did not follow shared boundary", err) - } - d, err := Describe(kind, uuid.NewString()) - if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { - t.Fatal(d, err) - } - if _, err := Normalize(sandbox.Selection{Provider: kind, E2B: &sandbox.E2BConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("mixed configuration admitted", err) - } -} - -func TestRetainedLimitUsesRegisteredCapabilities(t *testing.T) { - const kind = "capacity-test-provider" - defer delete(adapters, kind) - for _, checkpoint := range []bool{false, true} { - operations := docker.Operations - if checkpoint { - operations = microsandbox.Operations - } - adapters[kind] = Adapter{Mode: "nodes", Operations: operations} - for _, retained := range []int{0, 20} { - want := 10 - if checkpoint { - want = retained - } - if got := RetainedLimit(kind, 10, retained); got != want { - t.Fatalf("checkpoint=%v retained=%d: got %d, want %d", checkpoint, retained, got, want) - } - } - } -} diff --git a/services/agents-api/internal/store/admin_history.go b/services/agents-api/internal/store/admin_history.go deleted file mode 100644 index fb6545a5a..000000000 --- a/services/agents-api/internal/store/admin_history.go +++ /dev/null @@ -1,110 +0,0 @@ -package store - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type AdminAuditFilter struct { - ProjectID, ResourceType, ResourceID, Action, After string - CreatedAfter, CreatedBefore *time.Time - Limit int -} - -// AdminAuditOperation is one administrator write. ProjectID is null for -// deployment-wide writes, such as deployment default model providers. -type AdminAuditOperation struct { - ID string `json:"id"` - CreatedAt time.Time `json:"created_at"` - AdminCredentialID string `json:"admin_credential_id"` - ActorLabel string `json:"actor_label"` - Action string `json:"action"` - ProjectID *string `json:"project_id" extensions:"x-nullable"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - ResultIDs json.RawMessage `json:"result_ids" swaggertype:"array,object"` - RequestID string `json:"request_id"` - TraceID string `json:"trace_id"` -} -type AdminAuditPage struct { - Data []AdminAuditOperation `json:"data"` - HasMore bool `json:"has_more"` - NextCursor string `json:"next_cursor"` -} - -func (s *Store) ListAdminAudit(ctx context.Context, filter AdminAuditFilter) (AdminAuditPage, error) { - page := AdminAuditPage{Data: []AdminAuditOperation{}} - if filter.Limit == 0 { - filter.Limit = 50 - } - if filter.Limit < 1 || filter.Limit > 100 || !auditText(filter.ProjectID, 128, false) || !auditText(filter.ResourceType, 64, false) || !auditText(filter.ResourceID, 256, false) || !auditText(filter.Action, 64, false) || filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { - return page, ErrInvalidInput - } - normalized := filter - normalized.After = "" - normalized.Limit = 0 - if normalized.CreatedAfter != nil { - v := normalized.CreatedAfter.UTC() - normalized.CreatedAfter = &v - } - if normalized.CreatedBefore != nil { - v := normalized.CreatedBefore.UTC() - normalized.CreatedBefore = &v - } - raw, _ := json.Marshal(normalized) - digest := sha256.Sum256(raw) - scope := hex.EncodeToString(digest[:]) - params := sqlc.ListAdminAuditLogParams{ProjectID: filter.ProjectID, ResourceType: filter.ResourceType, ResourceID: filter.ResourceID, Action: filter.Action, CreatedAfter: auditTimestamp(filter.CreatedAfter), CreatedBefore: auditTimestamp(filter.CreatedBefore), AfterID: pgtype.UUID{Valid: true}, PageLimit: int32(filter.Limit + 1)} - if filter.After != "" { - raw, err := base64.RawURLEncoding.DecodeString(filter.After) - var cursor writeAuditCursor - if len(filter.After) > 1024 || err != nil || json.Unmarshal(raw, &cursor) != nil || cursor.Scope != scope { - return page, ErrInvalidInput - } - params.AfterID, err = parseID(cursor.ID) - if err != nil { - return page, ErrInvalidInput - } - params.AfterTime, err = s.queries.AdminAuditCursor(ctx, params.AfterID) - if errors.Is(err, pgx.ErrNoRows) { - return page, ErrInvalidInput - } - if err != nil { - return page, err - } - } - rows, err := s.queries.ListAdminAuditLog(ctx, params) - if err != nil { - return page, err - } - page.HasMore = len(rows) > filter.Limit - if page.HasMore { - rows = rows[:filter.Limit] - } - for _, row := range rows { - page.Data = append(page.Data, AdminAuditOperation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: auditProjectID(row.ProjectID), ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResultIDs: row.ResultIds, RequestID: row.RequestID, TraceID: row.TraceID}) - } - if page.HasMore { - raw, _ := json.Marshal(writeAuditCursor{ID: page.Data[len(page.Data)-1].ID, Scope: scope}) - page.NextCursor = base64.RawURLEncoding.EncodeToString(raw) - } - return page, nil -} - -func auditProjectID(id pgtype.UUID) *string { - if !id.Valid { - return nil - } - value := uuid.UUID(id.Bytes).String() - return &value -} diff --git a/services/agents-api/internal/store/admin_session_archive.go b/services/agents-api/internal/store/admin_session_archive.go deleted file mode 100644 index 3235c8bdc..000000000 --- a/services/agents-api/internal/store/admin_session_archive.go +++ /dev/null @@ -1,160 +0,0 @@ -package store - -import ( - "context" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// ManagedSessionArchive reports resource disposal, not archive request provenance -// or Turn settlement. Existing expiry and failed provisioning use the same states. -type ManagedSessionArchive struct { - SessionID string `json:"session_id"` - EnvironmentID string `json:"environment_id"` - State string `json:"state"` -} - -// ArchiveManagedSession ends a managed Environment's lifetime while keeping its -// public Session, history and persisted files. The lifecycle owner performs the -// external cleanup; only its existing confirmation can release an allocation. -func (s *Store) ArchiveManagedSession(ctx context.Context, tenantID, sessionID string, expectedGeneration uint64) (ManagedSessionArchive, error) { - return s.archiveManagedSession(ctx, tenantID, sessionID, expectedGeneration, nil) -} - -// A reset instance is identified by its persisted request time as well as its -// generation, preventing a cancelled clear's candidates from affecting its successor. -func (s *Store) ArchiveSandboxResetSession(ctx context.Context, tenantID, sessionID string, generation uint64, requestedAt time.Time) (ManagedSessionArchive, error) { - return s.archiveManagedSession(ctx, tenantID, sessionID, generation, &requestedAt) -} - -var ErrSandboxResetSessionBusy = errors.New("the hosted Session is busy") - -func (s *Store) archiveManagedSession(ctx context.Context, tenantID, sessionID string, expectedGeneration uint64, resetRequestedAt *time.Time) (ManagedSessionArchive, error) { - if s.executionLease == nil { - return ManagedSessionArchive{}, ErrInvalidInput - } - tenant, err := parseID(tenantID) - if err != nil { - return ManagedSessionArchive{}, err - } - var result ManagedSessionArchive - err = s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - // Session precedes deployment, matching Turn, allocation and input admission. - deployment, err := q.LockRuntimeDeployment(ctx) - if err != nil { - return err - } - if uint64(deployment.Generation) != expectedGeneration { - return &SandboxGenerationStaleError{uint64(deployment.Generation)} - } - if !deployment.WebManaged || !deployment.InstallationID.Valid { - return ErrSandboxDeploymentConflict - } - if deployment.ProviderKind == "" { - return ErrSandboxNotConfigured - } - environment, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: session}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrInvalidInput - } - if err != nil { - return err - } - kind, err := storedEnvironmentType(environment) - if err != nil || kind != "openai_hosted" { - return ErrInvalidInput - } - if resetRequestedAt != nil { - if !deployment.ResetClear.Valid || !deployment.ResetRequestedAt.Time.Equal(*resetRequestedAt) { - return ErrSandboxDeploymentConflict - } - if deployment.ResetClear.String == "auto" { - busy, err := q.SessionBlocksAutoReset(ctx, session) - if err != nil { - return err - } - if busy { - return ErrSandboxResetSessionBusy - } - } - if environment.Environment.Status == "failed" || environment.Environment.Status == "expired" { - result, err = getManagedSessionArchive(ctx, q, tenant, session) - return err - } - source, err := sandboxResetAudit(deployment) - if err != nil { - return err - } - project, err := q.GetSandboxResetProject(ctx, tenant) - if err != nil { - return err - } - source.ProjectID = runtimeUUID(project) - ctx = adminaudit.WithSource(ctx, source) - } - allocation, err := q.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: tenant, EnvironmentID: environment.Environment.ID}) - allocated := err == nil - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - if allocated && allocation.RuntimeAllocation.State != "released" && allocation.RuntimeAllocation.ProviderKey != deployment.InstallationID { - return ErrSandboxDeploymentConflict - } - if err := withEnvironmentInputActivity(ctx, q, session, func() error { - if environment.Environment.Status != "failed" && environment.Environment.Status != "expired" { - if err := q.SetEnvironmentConnectionStatus(ctx, sqlc.SetEnvironmentConnectionStatusParams{ID: environment.Environment.ID, Status: "expired"}); err != nil { - return err - } - } - return cancelSessionWork(ctx, q, session) - }); err != nil { - return err - } - if allocated && allocation.RuntimeAllocation.State != "released" { - if _, err := q.RevokeArchivedRuntimeDevice(ctx, sqlc.RevokeArchivedRuntimeDeviceParams{TenantID: tenant, DeviceID: allocation.RuntimeAllocation.DeviceID, SessionID: session}); err != nil { - return err - } - if _, err := q.RequestRuntimeCleanup(ctx, allocation.RuntimeAllocation.ID); err != nil { - return err - } - } else if !allocated { - if err := q.ReleaseUnallocatedRuntimePlacement(ctx, session); err != nil { - return err - } - } - if err := recordAdminMutation(ctx, q, tenantID, "archive", "session", runtimeUUID(session)); err != nil { - return err - } - result, err = getManagedSessionArchive(ctx, q, tenant, session) - return err - }) - return result, err -} - -// GetManagedSessionArchive reads one database snapshot and never contacts compute. -func (s *Store) GetManagedSessionArchive(ctx context.Context, tenantID, sessionID string) (ManagedSessionArchive, error) { - tenant, err := parseID(tenantID) - if err != nil { - return ManagedSessionArchive{}, err - } - return getManagedSessionArchive(ctx, s.queries, tenant, parsePathID(sessionID)) -} - -func getManagedSessionArchive(ctx context.Context, q *sqlc.Queries, tenant, session pgtype.UUID) (ManagedSessionArchive, error) { - row, err := q.GetManagedSessionArchive(ctx, sqlc.GetManagedSessionArchiveParams{TenantID: tenant, ID: session}) - if errors.Is(err, pgx.ErrNoRows) { - return ManagedSessionArchive{}, ErrNotFound - } - if err != nil { - return ManagedSessionArchive{}, err - } - if row.EnvironmentType != "openai_hosted" { - return ManagedSessionArchive{}, ErrInvalidInput - } - return ManagedSessionArchive{SessionID: runtimeUUID(row.SessionID), EnvironmentID: runtimeUUID(row.EnvironmentID), State: row.State}, nil -} diff --git a/services/agents-api/internal/store/admin_session_archive_test.go b/services/agents-api/internal/store/admin_session_archive_test.go deleted file mode 100644 index 18c761fc3..000000000 --- a/services/agents-api/internal/store/admin_session_archive_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func managedArchiveFixture(t *testing.T) (*Store, *Store, string) { - t.Helper() - _, pool := newManagedTestStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - installation := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { - t.Fatal(err) - } - if _, err := w.InitializeSandboxDeployment(t.Context(), installation, e2bSelection()); err != nil { - t.Fatal(err) - } - return s, w, installation -} - -func managedArchiveSession(t *testing.T, s *Store, input CreateSessionInput) (string, Session) { - t.Helper() - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'admin-archive',$2)", tenant, tenant); err != nil { - t.Fatal(err) - } - if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Archive fixture',$1,'service_account',$2)", tenant, "project:"+tenant); err != nil { - t.Fatal(err) - } - return tenant, session -} - -func archiveAllocation(t *testing.T, w *Store, tenant string, session Session, installation string) RuntimeAllocation { - t.Helper() - owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - return owner -} - -func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - input := managerSessionInput(uuid.NewString()) - input.InitialInputs = []Input{{Kind: "message", Payload: json.RawMessage(`{"text":"waiting"}`)}} - tenant, session := managedArchiveSession(t, s, input) - ctx := adminDeleteContext(t.Context(), tenant, uuid.NewString()) - active, err := s.GetManagedSessionArchive(t.Context(), tenant, session.ID) - if err != nil || active.State != "active" || active.SessionID != session.ID || active.EnvironmentID != session.Environment.ID { - t.Fatal("unallocated Session status", active, err) - } - for _, generation := range []uint64{0, 2, ^uint64(0)} { - if _, err := w.ArchiveManagedSession(ctx, tenant, session.ID, generation); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("archive accepted wrong generation", generation, err) - } - } - if _, err := s.ArchiveManagedSession(ctx, tenant, session.ID, 1); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unleased archive accepted", err) - } - for _, other := range []string{uuid.NewString(), "malformed"} { - if _, err := w.ArchiveManagedSession(ctx, tenant, other, 1); !errors.Is(err, ErrNotFound) { - t.Fatal("unknown archive", err) - } - if _, err := s.GetManagedSessionArchive(ctx, tenant, other); !errors.Is(err, ErrNotFound) { - t.Fatal("unknown status", err) - } - } - if _, err := w.ArchiveManagedSession(ctx, uuid.NewString(), session.ID, 1); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign archive", err) - } - if _, err := s.GetManagedSessionArchive(ctx, uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign status", err) - } - result, err := w.ArchiveManagedSession(ctx, tenant, session.ID, 1) - if err != nil || result.State != "released" { - t.Fatal("unallocated archive", result, err) - } - row, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || row.Environment.Status != "expired" || row.EnvironmentFailure != nil || row.PendingInput || row.EnvironmentInputActivity != nil || row.LastTurn != nil { - t.Fatal("archive fabricated failed execution", row, err) - } - var reservationState string - if err := s.pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE session_id=$1", session.ID).Scan(&reservationState); err != nil || reservationState != "cancelled" { - t.Fatal("archive left pending initial input", reservationState, err) - } - before := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events") - retry, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1) - if err != nil || retry != result || !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events")) { - t.Fatal("archive retry changed Session history", retry, err) - } - if status, err := s.GetManagedSessionArchive(ctx, tenant, session.ID); err != nil || status != result { - t.Fatal("status differs from committed archive", status, err) - } - if _, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())); !errors.Is(err, ErrInvalidInput) { - t.Fatal("archived Environment allocated after archive", err) - } - if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}); !errors.Is(err, ErrEnvironmentUnavailable) { - t.Fatal("archived Environment accepted new input", err) - } - view, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || view.Resources.Pending != 0 || view.Resources.Allocations != 0 { - t.Fatal("unallocated archive still blocks switching", view, err) - } -} - -func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - owner := archiveAllocation(t, w, tenant, session, installation) - file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource([]byte("retained source file"))) - if err != nil { - t.Fatal(err) - } - input := submitMessage(t, s, tenant, session.ID, "completed") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) - if err := w.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained"}`)}}); err != nil { - t.Fatal(err) - } - body := []byte("retained artifact") - if err := s.StageTurnArtifacts(t.Context(), tenant, session.ID, input.TurnID, session.Environment.ID, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/result.txt": body}))); err != nil { - t.Fatal(err) - } - transition(t, w, tenant, session.ID, input.TurnID, TurnInProgress, TurnCompleted) - history := adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata") - request := uuid.NewString() - result, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, request), tenant, session.ID, 1) - if err != nil || result.State != "cleanup_pending" { - t.Fatal(result, err) - } - assertAdminMutationAudit(t, s, tenant, request, "archive", "session", session.ID) - if !reflect.DeepEqual(history, adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata")) { - t.Fatal("archive changed persisted history or artifacts") - } - if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { - t.Fatal("archive retained runtime authority", err) - } - if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { - t.Fatal("archive discarded unknown Create ownership", err) - } - replay, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) - if err != nil || !replay.Replayed || replay.ID != owner.ID || replay.DeviceID != owner.DeviceID || replay.State != "cleanup_pending" { - t.Fatal("late provisioning retry replaced archived allocation", replay, err) - } - if _, err := w.RequestRuntimeCleanup(t.Context(), owner); err != nil { - t.Fatal(err) - } - current, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || current.EnvironmentFailure != nil || current.LastTurn == nil || current.LastTurn.Status != TurnCompleted || current.Environment.Status != "expired" { - t.Fatal("cleanup rewrote completed outcome", current, err) - } - if _, err := w.SettleRuntimeCreation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if result, err := s.GetManagedSessionArchive(t.Context(), tenant, session.ID); err != nil || result.State != "released" { - t.Fatal("release not reflected", result, err) - } - page, err := s.ListSessionArtifacts(t.Context(), tenant, session.ID, "", "", 100, true) - if err != nil || len(page.Artifacts) != 1 { - t.Fatal(page, err) - } - if err := s.ReadSessionArtifact(t.Context(), tenant, session.ID, page.Artifacts[0].ID, func(_ SessionArtifact, r io.Reader) error { - got, err := io.ReadAll(r) - if !bytes.Equal(got, body) { - t.Error("archive damaged published artifact bytes") - } - return err - }); err != nil { - t.Fatal(err) - } - if err := s.ReadSourceFile(t.Context(), tenant, file.ID, func(_ SourceFile, r io.Reader) error { - got, err := io.ReadAll(r) - if string(got) != "retained source file" { - t.Error("archive damaged source file bytes") - } - return err - }); err != nil { - t.Fatal(err) - } -} - -func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - archiveAllocation(t, w, tenant, session, installation) - input := submitMessage(t, s, tenant, session.ID, "running") - transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) - rejectAdminAuditInsert(t, s) - tables := []string{"sessions", "environments", "turns", "session_events", "devices", "runtime_allocations", "runtime_placements", "environment_input_reservations", "admin_audit_log"} - before := adminMutationSnapshot(t, s, tables...) - count := adminAuditRejections(t, s) - _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, rejectedAdminRequest), tenant, session.ID, 1) - requireAdminAuditFailure(t, s, err, count) - if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, tables...)) { - t.Fatal("failed audit retained archive, revocation or cancellation") - } - if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1); err != nil { - t.Fatal(err) - } - turn, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) - if err != nil || turn.Status != TurnInProgress || turn.CancelRequestedAt.IsZero() { - t.Fatal("archive did not request cancellation or fabricated settlement", turn, err) - } -} - -func TestManagedSessionArchivePreservesFailuresAndRejectsSelfHosted(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - owner := archiveAllocation(t, w, tenant, session, installation) - if _, err := s.pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID); err != nil { - t.Fatal(err) - } - if err := w.FailEnvironmentInitialization(t.Context(), EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}, ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { - t.Fatal(err) - } - failed, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || failed.EnvironmentFailure == nil { - t.Fatal("failure fixture", err) - } - otherTenant, selfHosted := managedArchiveSession(t, s, environmentInput(uuid.NewString(), "self_hosted", "/workspace")) - if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1); err != nil { - t.Fatal(err) - } - after, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(after.EnvironmentFailure, failed.EnvironmentFailure) || after.Environment.Status != "failed" { - t.Fatal("archive changed recorded provisioning failure", after, err) - } - before := adminMutationSnapshot(t, s, "sessions", "environments", "admin_audit_log") - if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), otherTenant, uuid.NewString()), otherTenant, selfHosted.ID, 1); !errors.Is(err, ErrInvalidInput) { - t.Fatal("self-hosted archive accepted", err) - } - if _, err := s.GetManagedSessionArchive(t.Context(), otherTenant, selfHosted.ID); !errors.Is(err, ErrInvalidInput) { - t.Fatal("self-hosted cleanup projected", err) - } - if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "sessions", "environments", "admin_audit_log")) { - t.Fatal("self-hosted archive changed resources") - } -} diff --git a/services/agents-api/internal/store/admin_summary.go b/services/agents-api/internal/store/admin_summary.go deleted file mode 100644 index e36718ff8..000000000 --- a/services/agents-api/internal/store/admin_summary.go +++ /dev/null @@ -1,122 +0,0 @@ -package store - -import ( - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type AdminSummaryFilter struct{ CreatedAfter, CreatedBefore *time.Time } -type AdminAssetCounts struct { - Agents int64 `json:"agents"` - Skills int64 `json:"skills"` - EnvironmentTemplates int64 `json:"environment_templates"` - Files int64 `json:"files"` - Vaults int64 `json:"vaults"` - Credentials int64 `json:"credentials"` -} - -// ReadAdminSummary visits one space's Sessions from one read-only snapshot. The -// visitor reuses the API's Session projection instead of creating another status -// or usage model. Paging keeps the stored configurations out of an unbounded slice. -func (s *Store) ReadAdminSummary(ctx context.Context, tenantID string, filter AdminSummaryFilter, visit func(Session, *string) error) (AdminAssetCounts, error) { - var counts AdminAssetCounts - tenant, err := parseID(tenantID) - if err != nil { - return counts, err - } - if visit == nil || filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { - return counts, ErrInvalidInput - } - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - raw, err := q.AdminAssetCounts(ctx, tenant) - if err != nil { - return err - } - counts = AdminAssetCounts{Agents: raw.Agents, Skills: raw.Skills, EnvironmentTemplates: raw.EnvironmentTemplates, Files: raw.Files, Vaults: raw.Vaults, Credentials: raw.Credentials} - params := sqlc.AdminSummarySessionsParams{TenantID: tenant, CreatedAfter: auditTimestamp(filter.CreatedAfter), CreatedBefore: auditTimestamp(filter.CreatedBefore), AfterID: pgtype.UUID{Valid: true}} - for { - rows, err := q.AdminSummarySessions(ctx, params) - if err != nil { - return err - } - for _, row := range rows { - session, err := sessionFromRow(row.Session) - if err != nil { - return err - } - session, err = readSessionActivity(ctx, q, session) - if err != nil { - return err - } - var creator *string - if row.CreationKeyID.Valid { - id := row.CreationKeyID.String - creator = &id - } - if err := visit(session, creator); err != nil { - return err - } - params.AfterID = row.Session.ID - } - if len(rows) < 100 { - return nil - } - } - }) - return counts, err -} - -type AdminRuntimeTarget struct{ SessionID, TenantID string } -type AdminRuntimeTargetPage struct { - Data []AdminRuntimeTarget - HasMore bool -} - -func (s *Store) ListAdminRuntimeTargets(ctx context.Context, tenantIDs []string, after string, limit int, ascending bool) (AdminRuntimeTargetPage, error) { - page := AdminRuntimeTargetPage{Data: []AdminRuntimeTarget{}} - if limit < 1 || limit > 100 { - return page, ErrInvalidInput - } - tenants := make([]pgtype.UUID, 0, len(tenantIDs)) - for _, value := range tenantIDs { - id, err := parseID(value) - if err != nil { - return page, err - } - tenants = append(tenants, id) - } - params := sqlc.AdminRuntimeTargetsParams{TenantIds: tenants, Ascending: ascending, AfterID: pgtype.UUID{Valid: true}, PageLimit: int32(limit + 1)} - if after != "" { - var err error - params.AfterID, err = parseID(after) - if err != nil { - return page, ErrNotFound - } - params.AfterTime, err = s.queries.AdminRuntimeCursor(ctx, sqlc.AdminRuntimeCursorParams{ID: params.AfterID, TenantIds: tenants}) - if errors.Is(err, pgx.ErrNoRows) { - return page, ErrNotFound - } - if err != nil { - return page, err - } - } - rows, err := s.queries.AdminRuntimeTargets(ctx, params) - if err != nil { - return page, err - } - page.HasMore = len(rows) > limit - if page.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - page.Data = append(page.Data, AdminRuntimeTarget{SessionID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String()}) - } - return page, nil -} diff --git a/services/agents-api/internal/store/agents.go b/services/agents-api/internal/store/agents.go deleted file mode 100644 index e6e3a3983..000000000 --- a/services/agents-api/internal/store/agents.go +++ /dev/null @@ -1,111 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -// SavedAgent is reusable configuration owned by an execution tenant. It has no -// engine binding or live execution state; Session snapshots are separate objects. -type SavedAgent struct { - ID string - TenantID string - Metadata map[string]string - Configuration json.RawMessage - CreatedAt time.Time - UpdatedAt time.Time -} - -type CreateAgentInput struct { - ModelProvider *v1.ModelProviderInput - Metadata map[string]string - Configuration json.RawMessage -} - -// CreateAgent stores caller-validated, credential-free configuration. Public -// defaults and schema validation belong to the API, not an execution adapter. -// Each call creates a new resource; this primitive supplies no retry semantics. -func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAgentInput) (SavedAgent, error) { - tenant, err := parseID(tenantID) - if err != nil { - return SavedAgent{}, err - } - metadata, err := encodeMetadata(input.Metadata) - if err != nil { - return SavedAgent{}, err - } - if len(input.Configuration) == 0 || len(input.Configuration) > 512*1024 { - return SavedAgent{}, fmt.Errorf("%w: configuration must be an object of at most 512 KiB", ErrInvalidInput) - } - configuration, err := canonicalJSONObject(input.Configuration) - if err != nil { - return SavedAgent{}, err - } - if err := validateAgentModelExecution(configuration, input.ModelProvider); err != nil { - return SavedAgent{}, err - } - var created SavedAgent - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Metadata: metadata, Configuration: configuration, - }) - if err != nil { - return err - } - if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), row.ID, input.ModelProvider); err != nil { - return err - } - created, err = agentFromRow(row) - if err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "create", "agent", created.ID, "", AuditResource{Type: "agent", ID: created.ID}) - }) - if err != nil { - return SavedAgent{}, fmt.Errorf("create agent: %w", err) - } - return created, nil -} - -// GetAgent scopes every lookup to the authenticated caller's tenant. -func (s *Store) GetAgent(ctx context.Context, tenantID, agentID string) (SavedAgent, error) { - tenant, err := parseID(tenantID) - if err != nil { - return SavedAgent{}, err - } - id, err := parseID(agentID) - if err != nil { - return SavedAgent{}, err - } - row, err := s.queries.GetAgent(ctx, sqlc.GetAgentParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return SavedAgent{}, ErrNotFound - } - if err != nil { - return SavedAgent{}, fmt.Errorf("get agent: %w", err) - } - return agentFromRow(row) -} - -func agentFromRow(row sqlc.Agent) (SavedAgent, error) { - agent := SavedAgent{ - ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), - Configuration: row.Configuration, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, - } - if err := json.Unmarshal(row.Metadata, &agent.Metadata); err != nil { - return SavedAgent{}, fmt.Errorf("decode agent metadata: %w", err) - } - return agent, nil -} diff --git a/services/agents-api/internal/store/agents_delete.go b/services/agents-api/internal/store/agents_delete.go deleted file mode 100644 index bd3423352..000000000 --- a/services/agents-api/internal/store/agents_delete.go +++ /dev/null @@ -1,40 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// DeleteAgent removes a saved resource independently of execution snapshots. -func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) (string, error) { - tenant, err := parseID(tenantID) - if err != nil { - return "", err - } - id, err := parseID(agentID) - if err != nil { - return "", err - } - var deletedID string - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - deleted, err := q.DeleteAgent(ctx, sqlc.DeleteAgentParams{TenantID: tenant, ID: id}) - if err != nil { - return err - } - deletedID = uuid.UUID(deleted.Bytes).String() - return recordWriteAudit(ctx, q, tenantID, "delete", "agent", deletedID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", fmt.Errorf("delete agent: %w", err) - } - return deletedID, nil -} diff --git a/services/agents-api/internal/store/agents_list.go b/services/agents-api/internal/store/agents_list.go deleted file mode 100644 index f3a546945..000000000 --- a/services/agents-api/internal/store/agents_list.go +++ /dev/null @@ -1,51 +0,0 @@ -package store - -import ( - "context" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -type AgentPage struct { - Agents []SavedAgent - NextCursor string -} - -func (s *Store) ListAgents(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (AgentPage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return AgentPage{}, err - } - if limit < 1 || limit > 100 { - return AgentPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) - } - params := sqlc.ListAgentsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} - if cursor != "" { - after, err := s.GetAgent(ctx, tenantID, lookupCursor(cursor)) - if err != nil { - return AgentPage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - params.AfterID, _ = parseID(after.ID) - } - rows, err := s.queries.ListAgents(ctx, params) - if err != nil { - return AgentPage{}, fmt.Errorf("list agents: %w", err) - } - page := AgentPage{Agents: make([]SavedAgent, 0, min(limit, len(rows)))} - if len(rows) > limit { - page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() - rows = rows[:limit] - } - for _, row := range rows { - agent, err := agentFromRow(row) - if err != nil { - return AgentPage{}, err - } - page.Agents = append(page.Agents, agent) - } - return page, nil -} diff --git a/services/agents-api/internal/store/agents_update.go b/services/agents-api/internal/store/agents_update.go deleted file mode 100644 index 2adca88a4..000000000 --- a/services/agents-api/internal/store/agents_update.go +++ /dev/null @@ -1,110 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// UpdateAgentInput contains validated field replacements, not a full snapshot. -// Core extension subfields merge independently. A nil metadata pointer preserves -// the existing map; a supplied map replaces it. ModelProviderSet distinguishes -// omission from replacement or an explicit nil provider, which clears the secret. -type UpdateAgentInput struct { - ModelProvider *v1.ModelProviderInput - ModelProviderSet bool - Configuration json.RawMessage - Metadata *map[string]string -} - -func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input UpdateAgentInput) (SavedAgent, error) { - tenant, err := parseID(tenantID) - if err != nil { - return SavedAgent{}, err - } - id, err := parseID(agentID) - if err != nil { - return SavedAgent{}, err - } - raw := input.Configuration - if len(raw) == 0 { - raw = json.RawMessage(`{}`) - } - if len(raw) > 512*1024 { - return SavedAgent{}, ErrInvalidInput - } - raw, err = canonicalJSONObject(raw) - if err != nil { - return SavedAgent{}, err - } - var patch map[string]json.RawMessage - if err := json.Unmarshal(raw, &patch); err != nil { - return SavedAgent{}, err - } - var metadata []byte - if input.Metadata != nil { - metadata, err = encodeMetadata(*input.Metadata) - if err != nil { - return SavedAgent{}, err - } - } - var updated SavedAgent - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.LockAgent(ctx, sqlc.LockAgentParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - var configuration map[string]json.RawMessage - if err := json.Unmarshal(row.Configuration, &configuration); err != nil { - return err - } - if err := mergeAgentConfiguration(configuration, patch); err != nil { - return err - } - merged, err := json.Marshal(configuration) - if err != nil { - return err - } - merged, err = canonicalJSONObject(merged) - if err != nil { - return err - } - if len(merged) > 512*1024 { - return ErrInvalidInput - } - if err := validateAgentModelExecution(merged, input.ModelProvider); err != nil { - return err - } - if input.ModelProviderSet { - if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), id, input.ModelProvider); err != nil { - return err - } - } - if input.Metadata == nil { - metadata = row.Metadata - } - row, err = q.UpdateAgent(ctx, sqlc.UpdateAgentParams{TenantID: tenant, ID: id, Configuration: merged, Metadata: metadata}) - if err != nil { - return err - } - updated, err = agentFromRow(row) - if err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "update", "agent", updated.ID, "") - }) - if err != nil { - return SavedAgent{}, fmt.Errorf("update agent: %w", err) - } - return updated, nil -} diff --git a/services/agents-api/internal/store/auth_fixture_test.go b/services/agents-api/internal/store/auth_fixture_test.go deleted file mode 100644 index 3c7459776..000000000 --- a/services/agents-api/internal/store/auth_fixture_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package store_test - -import ( - "context" - "encoding/hex" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } -type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding - -func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { - if b, ok := f[digest]; ok { - return b, nil - } - return store.ProjectAPIKeyBinding{}, store.ErrNotFound -} -func newTestAuthenticator(keys []testAPIKey) (*api.Authenticator, error) { - resolver := fixtureKeyResolver{} - for _, k := range keys { - p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} - if err := p.Validate(); err != nil { - return nil, err - } - digest, err := hex.DecodeString(k.TokenSHA256) - if err != nil || len(digest) != 32 { - return nil, errors.New("invalid fixture digest") - } - if _, exists := resolver[k.TokenSHA256]; exists { - return nil, errors.New("duplicate fixture digest") - } - resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} - } - return api.NewDatabaseAuthenticator(resolver) -} diff --git a/services/agents-api/internal/store/claude_mcp_test.go b/services/agents-api/internal/store/claude_mcp_test.go deleted file mode 100644 index 41a753022..000000000 --- a/services/agents-api/internal/store/claude_mcp_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "fmt" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { - for _, requirement := range []string{"anonymous", "bearer", "required"} { - authenticated, required := requirement == "bearer", requirement == "required" - for _, prebound := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/bound=%t", requirement, prebound), func(t *testing.T) { - h := newFunctionHarness(t) - configuration, token := mcpWorkerConfiguration, "" - if authenticated { - configuration, token = mcpBearerWorkerConfiguration(t, h) - } - if required { - configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) - } - claudeSession(t, h, configuration, prebound) - // Base MCP support does not imply authentication or required initialization. - caps := prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(authenticated || required), Preparation: proto.CapabilitySupported}) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) - awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Claude MCP heartbeat", func() bool { - peer, _ := h.registry.LookupDevice(h.device.ID) - info, found, known := peer.AgentKindStatus("claude_sdk") - return known && found && info.Capabilities.MCPHTTPTools == (authenticated || required) && !info.Capabilities.MCPHTTPBearerAuth && !info.Capabilities.MCPHTTPRequired - }) - input := h.message("start", "Use declared tools only") - if prebound { - if result := <-h.run(t.Context(), input.TurnID); result.err == nil { - t.Fatal("final preclaim accepted a runtime without MCP support") - } - } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - worker, err := execution.StartWorker(ctx, h.d) - if err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Error("worker did not stop") - } - }() - time.Sleep(650 * time.Millisecond) - turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { - t.Fatal("incapable runtime claimed work", turn, err) - } - if !prebound { - if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("bound an incapable runtime", err) - } - } - caps.MCPHTTPTools, caps.MCPHTTPBearerAuth = proto.CapabilitySupported, proto.CapabilityFromBool(authenticated) - caps.MCPHTTPRequired = proto.CapabilityFromBool(required) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) - var prompt proto.PromptRequestPayload - if h.read(testExecutionRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { - t.Fatal("missing typed MCP dispatch") - } - server := (*prompt.MCPHTTPServers)[0] - if server.ServerLabel != "tickets" || server.AllowedTools == nil || len(*server.AllowedTools) != 0 || server.Required != required || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents { - t.Fatal("MCP configuration changed during dispatch") - } - endpoint := "http://127.0.0.1:9191/mcp" - if authenticated { - endpoint = "https://mcp.example/tools" - } - if server.ServerURL != endpoint || (token != "" && (server.BearerToken == nil || *server.BearerToken != token)) || (token == "" && server.BearerToken != nil) { - t.Fatal("dispatch lost scoped authentication or authenticated an anonymous server") - } - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) - waitTurn(t, h, input.TurnID, store.TurnCompleted) - }) - } - } -} - -func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { - for _, profile := range []string{"missing required capability", "reserved label"} { - t.Run(profile, func(t *testing.T) { - h := newDispatchHarness(t) - configuration := mcpWorkerConfiguration - switch profile { - case "missing required capability": - configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) - case "reserved label": - configuration = strings.Replace(configuration, `"tickets"`, `"functions"`, 1) - } - claudeSession(t, h, configuration, true) - caps := prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, Preparation: proto.CapabilitySupported}) - if profile == "missing required capability" { - caps.MCPHTTPRequired = proto.CapabilityUnsupported - } - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) - deadline := time.Now().Add(3 * time.Second) - for { - peer, _ := h.registry.LookupDevice(h.device.ID) - if info, _, _ := peer.AgentKindStatus("claude_sdk"); info.Capabilities.MCPHTTPBearerAuth { - break - } - if time.Now().After(deadline) { - t.Fatal("heartbeat missing") - } - time.Sleep(10 * time.Millisecond) - } - input := h.message("start", "Run") - if result := <-h.run(t.Context(), input.TurnID); result.err == nil { - t.Fatal("unsupported MCP configuration claimed") - } - turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) - if err != nil || turn.Status != store.TurnQueued { - t.Fatal(turn, err) - } - }) - } -} diff --git a/services/agents-api/internal/store/command_output_test.go b/services/agents-api/internal/store/command_output_test.go deleted file mode 100644 index 34b7889bb..000000000 --- a/services/agents-api/internal/store/command_output_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { - ctx := context.Background() - s, pool := store.NewTestStore(t) - defer pool.Close() - tenant := uuid.NewString() - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "command-output"}) - if err != nil { - t.Fatal(err) - } - input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"run commands"}`)) - if err != nil { - t.Fatal(err) - } - if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { - t.Fatal(err) - } - event := func(kind, raw string) store.ExecutionEvent { - return store.ExecutionEvent{Kind: kind, Payload: json.RawMessage(raw)} - } - batch := []store.ExecutionEvent{ - event("tool_call", `{"id":"cmd","stage":"before","observation":{"kind":"command","command":"run","status":"in_progress"}}`), - event("command_output", `{"id":"cmd","delta":"same\n"}`), - event("command_output", `{"id":"cmd","delta":"same\n"}`), - } - for range 2 { - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { - t.Fatal(err) - } - } - before, _ := s.SessionEventCursor(ctx, tenant, session.ID) - // A bad command reference rolls back preceding valid fragments and their events. - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, []store.ExecutionEvent{ - event("command_output", `{"id":"cmd","delta":"rollback"}`), - event("command_output", `{"id":"unknown","delta":"orphan"}`), - }); err == nil { - t.Fatal("unknown command accepted") - } - after, _ := s.SessionEventCursor(ctx, tenant, session.ID) - if before != after { - t.Fatal("rollback published output") - } - page, err := store.New(pool).ListItems(ctx, tenant, session.ID, "", 100, true) - if err != nil || len(page.Items) != 2 { - t.Fatalf("read draft: %+v %v", page, err) - } - if page.Items[1].Output != "same\nsame\n" { - t.Fatal("draft output lost", page.Items[1]) - } - final := []store.ExecutionEvent{ - event("tool_call", `{"id":"cmd","stage":"after","observation":{"kind":"command","command":"run","status":"completed","output":"authoritative","exit_code":0}}`), - event("command_output", `{"id":"cmd","delta":"late"}`), - event("tool_call", `{"id":"partial","stage":"before","observation":{"kind":"command","command":"wait","status":"in_progress"}}`), - event("command_output", `{"id":"partial","delta":"已观察\n"}`), - } - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { - t.Fatal(err) - } - if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { - t.Fatal(err) - } - // Reopening the Store recovers committed Items without creating events. - reopened := store.New(pool) - before, _ = s.SessionEventCursor(ctx, tenant, session.ID) - page, err = reopened.ListItems(ctx, tenant, session.ID, "", 100, true) - if err != nil || len(page.Items) != 3 { - t.Fatalf("recovery: %+v %v", page, err) - } - if page.Items[1].Status != "completed" || page.Items[1].Output != "authoritative" || page.Items[2].Status != "incomplete" || page.Items[2].Output != "已观察\n" { - t.Fatal("completion/cancellation lost command output", page.Items) - } - after, _ = s.SessionEventCursor(ctx, tenant, session.ID) - if before != after { - t.Fatal("query replayed events") - } - if _, err := reopened.ListSessionEvents(ctx, uuid.NewString(), session.ID, 0); !errors.Is(err, store.ErrNotFound) { - t.Fatal("foreign event access", err) - } - var fragments []string - added, done := map[string]bool{}, map[string]bool{} - indexes := map[string]int32{} - cursor := int64(0) - for { - changes, err := reopened.ListSessionEvents(ctx, tenant, session.ID, cursor) - if err != nil { - t.Fatal(err) - } - if len(changes) == 0 { - break - } - for _, change := range changes { - e := change.Event - if e.Item != nil && e.Item.Type == "command_execution" { - if e.Type == "agent.session.turn.item.added" { - added[e.Item.ID] = true - indexes[e.Item.ID] = *e.OutputIndex - } - if e.Type == "agent.session.turn.item.done" { - done[e.Item.ID] = true - } - } - if e.Type == "agent.output.command_execution_output.delta" { - if !added[e.ItemID] || done[e.ItemID] || e.OutputIndex == nil || *e.OutputIndex != indexes[e.ItemID] || e.TurnID != input.TurnID || e.SessionID != session.ID || e.EventID == "" { - t.Fatal("invalid command delta identity/order", e) - } - fragments = append(fragments, *e.Delta) - } - cursor = change.Sequence - } - } - if !reflect.DeepEqual(fragments, []string{"same\n", "same\n", "已观察\n"}) || len(done) != 2 { - t.Fatal("incorrect live fragments", fragments, done) - } -} - -func TestExecutionJournalsCommandOutputBeforeCancellation(t *testing.T) { - h := newDispatchHarness(t) - ctx := context.Background() - input := h.message("command", "run a command") - result := h.run(ctx, input.TurnID) - h.read(testExecutionRequest) - h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "cmd", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "wait", Status: "in_progress"}}) - h.write(input.TurnID, proto.TypeCommandOutput, proto.CommandOutputPayload{ID: "cmd", Delta: "partial"}) - if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { - t.Fatal(err) - } - env := h.read(proto.TypePromptCancel) - var cancel proto.PromptCancelPayload - if err := env.DecodePayload(&cancel); err != nil { - t.Fatal(err) - } - h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, store.TurnCancelled) - page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) - if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || page.Items[1].Output != "partial" { - t.Fatalf("journal/cancellation lost partial output: %+v %v", page, err) - } -} diff --git a/services/agents-api/internal/store/core_metrics.go b/services/agents-api/internal/store/core_metrics.go deleted file mode 100644 index f7c3ee812..000000000 --- a/services/agents-api/internal/store/core_metrics.go +++ /dev/null @@ -1,110 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type CoreExecutionSnapshot struct { - QueuedTurns, WaitingForDaemon, InProgressTurns int64 - OldestQueuedSeconds *float64 -} - -type CoreQueueWait struct{ P50, P95 *float64 } - -type CoreQueueWaitBucket struct { - Start time.Time - P95MS *float64 -} - -type CoreExecutionHistory struct { - Interrupted int64 - QueueWaitMS CoreQueueWait - Buckets []CoreQueueWaitBucket -} - -// ReadCoreExecutionSnapshot counts persisted root Turns across the deployment. -// WaitingForDaemon is the queued subset whose Session binding is absent from the -// supplied live registry IDs; callers must distinguish an unavailable registry -// from an observed empty one before using this method. Age is in seconds, and is -// nil when the queue is empty. No Session deletion filter hides operational state. -func (s *Store) ReadCoreExecutionSnapshot(ctx context.Context, now time.Time, connectedDeviceIDs []string) (CoreExecutionSnapshot, error) { - ids := make([]pgtype.UUID, 0, len(connectedDeviceIDs)) - for _, value := range connectedDeviceIDs { - id, err := parseID(value) - if err != nil { - return CoreExecutionSnapshot{}, err - } - ids = append(ids, id) - } - row, err := s.queries.CoreExecutionSnapshot(ctx, sqlc.CoreExecutionSnapshotParams{ - ConnectedDeviceIds: ids, ObservedAt: pgtype.Timestamptz{Time: now, Valid: true}, - }) - if err != nil { - return CoreExecutionSnapshot{}, err - } - result := CoreExecutionSnapshot{QueuedTurns: row.QueuedTurns, WaitingForDaemon: row.WaitingForDaemon, InProgressTurns: row.InProgressTurns} - if row.QueuedTurns > 0 { - result.OldestQueuedSeconds = &row.OldestQueuedSeconds - } - return result, nil -} - -// ReadCoreExecutionHistory reads a bounded, repeatable read-only snapshot of root -// Turn history. The interval is [start,end), with complete epoch-aligned buckets. -// Interruptions use failed Turns' completed_at and execution_interrupted error -// code. Queue waits use started_at-created_at in milliseconds, grouped by -// started_at, including retained history of deleted Sessions. Counts of an empty -// interval are zero; percentile values without observations are nil. Read errors -// invalidate the entire result and must not be presented as measured zeros. -func (s *Store) ReadCoreExecutionHistory(ctx context.Context, start, end time.Time, resolution time.Duration) (CoreExecutionHistory, error) { - span := end.Sub(start) - if resolution < time.Second || resolution%time.Second != 0 || span <= 0 || span > 7*24*time.Hour || - span%resolution != 0 || span/resolution > 1008 || start.Nanosecond() != 0 || end.Nanosecond() != 0 || - start.Unix()%int64(resolution/time.Second) != 0 || end.Unix()%int64(resolution/time.Second) != 0 { - return CoreExecutionHistory{}, ErrInvalidInput - } - result := CoreExecutionHistory{Buckets: make([]CoreQueueWaitBucket, int(span/resolution))} - for i := range result.Buckets { - result.Buckets[i].Start = start.Add(time.Duration(i) * resolution).UTC() - } - first, last := pgtype.Timestamptz{Time: start, Valid: true}, pgtype.Timestamptz{Time: end, Valid: true} - err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - var err error - result.Interrupted, err = q.CoreInterruptedTurns(ctx, sqlc.CoreInterruptedTurnsParams{RangeStart: first, RangeEnd: last}) - if err != nil { - return err - } - wait, err := q.CoreQueueWaitSummary(ctx, sqlc.CoreQueueWaitSummaryParams{RangeStart: first, RangeEnd: last}) - if err != nil { - return err - } - if wait.Samples > 0 { - result.QueueWaitMS = CoreQueueWait{P50: &wait.P50Ms, P95: &wait.P95Ms} - } - rows, err := q.CoreQueueWaitBuckets(ctx, sqlc.CoreQueueWaitBucketsParams{RangeStart: first, RangeEnd: last, ResolutionSeconds: int32(resolution / time.Second)}) - if err != nil { - return err - } - for _, row := range rows { - if row.Samples > 0 { - result.Buckets[row.BucketNumber].P95MS = &row.P95Ms - } - } - return nil - }) - if err != nil { - return CoreExecutionHistory{}, err - } - return result, nil -} - -// ReadCoreDatabaseSize measures the current PostgreSQL database in bytes. -func (s *Store) ReadCoreDatabaseSize(ctx context.Context) (int64, error) { - return s.queries.CoreDatabaseSize(ctx) -} diff --git a/services/agents-api/internal/store/deployment_provider_observations.go b/services/agents-api/internal/store/deployment_provider_observations.go deleted file mode 100644 index f5a301078..000000000 --- a/services/agents-api/internal/store/deployment_provider_observations.go +++ /dev/null @@ -1,49 +0,0 @@ -package store - -import ( - "context" - "strconv" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -// ObserveDeploymentModelProvider attempts one metadata UPDATE through the pool, -// never the leased execution connection. SQL verifies the committed root outcome -// and exact frozen revision without loading credentials. The metadata transaction -// installs server-side timeouts: client cancellation alone can leave PostgreSQL -// executing briefly after pgx has returned a deadline error. -func (s *Store) ObserveDeploymentModelProvider(ctx context.Context, tenantID, sessionID, turnID string) (int64, error) { - lookup, err := turnLookup(tenantID, sessionID, turnID) - if err != nil { - return 0, err - } - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - tx, err := s.pool.Begin(ctx) - if err != nil { - return 0, err - } - defer tx.Rollback(ctx) - deadline, _ := ctx.Deadline() - // Leave a small part of the overall budget for returning the server error - // and releasing this metadata-only transaction before the client deadline. - timeout := time.Until(deadline).Milliseconds() - 25 - if timeout <= 0 { - return 0, context.DeadlineExceeded - } - setting := strconv.FormatInt(timeout, 10) + "ms" - if _, err = tx.Exec(ctx, "SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $1, true)", setting); err != nil { - return 0, err - } - count, err := sqlc.New(tx).ObserveDeploymentModelProvider(ctx, sqlc.ObserveDeploymentModelProviderParams{ - TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID, - }) - if err != nil { - return 0, err - } - if err = tx.Commit(ctx); err != nil { - return 0, err - } - return count, nil -} diff --git a/services/agents-api/internal/store/deployment_provider_observations_test.go b/services/agents-api/internal/store/deployment_provider_observations_test.go deleted file mode 100644 index 94a375cc3..000000000 --- a/services/agents-api/internal/store/deployment_provider_observations_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strings" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" -) - -type providerObservationFixture struct { - s *Store - pool *pgxpool.Pool - tenant string - input CreateSessionInput - session Session -} - -func observationAdmin(t *testing.T) context.Context { - return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) -} -func newProviderObservationFixture(t *testing.T) providerObservationFixture { - t.Helper() - s, pool := newManagedTestStore(t) - provider := FixtureModelProvider("codex") - if _, err := s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *provider, Model: "fixture"}); err != nil { - t.Fatal(err) - } - snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") - if err != nil { - t.Fatal(err) - } - input := executionProjectionInput("deployment") - input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"none"}}`) - input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision = snapshot.Provider, "deployment", snapshot.Revision - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "deployment"} - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - return providerObservationFixture{s, pool, tenant, input, session} -} -func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nativeCode string) Turn { - t.Helper() - receipt := submitMessage(t, f.s, f.tenant, f.session.ID, uuid.NewString()) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, TurnQueued, TurnInProgress) - outcome, _ := json.Marshal(map[string]string{"error_code": coreCode, "engine_error_code": nativeCode}) - turn, err := f.s.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, status, outcome, "", receipt.Sequence) - if err != nil { - t.Fatal(err) - } - return turn -} -func (f providerObservationFixture) observe(t *testing.T, turn Turn, want int64) { - t.Helper() - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) - if err != nil || n != want { - t.Fatalf("observation writes=%d want=%d err=%v", n, want, err) - } -} -func (f providerObservationFixture) revision(t *testing.T) uuid.UUID { - t.Helper() - var rev uuid.UUID - if err := f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&rev); err != nil { - t.Fatal(err) - } - return rev -} -func TestDeploymentObservationSnapshotReplacementAndRetry(t *testing.T) { - f := newProviderObservationFixture(t) - original := f.revision(t) - // Resolution and insertion have independent boundaries: retain the tuple while - // a PUT replaces the default, then create with that exact earlier tuple. - before, err := f.s.DeploymentModelProvider(t.Context(), "codex") - if err != nil { - t.Fatal(err) - } - replacement := *before.Provider - replacement.APIKey = "different-fixture-key" - if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}); err != nil { - t.Fatal(err) - } - if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET updated_at='2000-01-01'"); err != nil { - t.Fatal(err) - } - input := f.input - input.IdempotencyKey = uuid.NewString() - stale, err := f.s.CreateSession(t.Context(), f.tenant, input) - if err != nil { - t.Fatal(err) - } - staleFixture := f - staleFixture.session = stale - if staleFixture.revision(t) != original { - t.Fatal("tuple revision changed during insertion") - } - frozen, err := f.s.SessionModelExecution(t.Context(), f.tenant, stale.ID) - if err != nil || frozen == nil || *frozen != *before.Provider { - t.Fatal("frozen tuple bundle changed", err) - } - staleFixture.observe(t, staleFixture.terminal(t, TurnFailed, "engine_failed", "authentication_error"), 0) - current, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - retry := f.input - retry.ModelProvider = current.Provider - retry.DeploymentProviderRevision = current.Revision - replay, err := f.s.CreateSession(t.Context(), f.tenant, retry) - if err != nil || replay.ID != f.session.ID || f.revision(t) != original { - t.Fatal("retry replaced frozen metadata", err) - } - if err = f.s.DeleteDeploymentModelProvider(observationAdmin(t), "codex"); err != nil { - t.Fatal(err) - } - if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *before.Provider, Model: "fixture"}); err != nil { - t.Fatal(err) - } - recreated, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - if recreated.Revision == original || recreated.Revision == current.Revision { - t.Fatal("revision reused") - } - frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) - if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { - t.Fatal("migration/replacement changed Session bundle", err) - } - f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) -} -func TestDeploymentObservationEligibilityAndReset(t *testing.T) { - allowed := []string{"authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"} - f := newProviderObservationFixture(t) - for _, code := range allowed { - if _, err := f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { - t.Fatal(err) - } - f.observe(t, f.terminal(t, TurnFailed, "engine_failed", code), 1) - } - for _, tc := range []struct{ status, core, code string }{{TurnFailed, "engine_failed", "context_length_exceeded"}, {TurnFailed, "engine_failed", "cyber_policy"}, {TurnFailed, "engine_failed", "harness_error"}, {TurnFailed, "engine_failed", "untrusted raw text"}, {TurnFailed, "input_not_applied", "authentication_error"}, {TurnFailed, "device_disconnected", "authentication_error"}, {TurnCancelled, "engine_failed", "authentication_error"}} { - f.observe(t, f.terminal(t, tc.status, tc.core, tc.code), 0) - } - success := f.terminal(t, TurnCompleted, "", "") - f.observe(t, success, 1) - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), uuid.NewString(), f.session.ID, success.ID) - if n != 0 || err != nil { - t.Fatal("foreign tenant observed", err) - } - view, _ := f.s.ListDeploymentModelProviders(t.Context()) - if view[0].LastUsedAt == nil || view[0].LastErrorAt == nil { - t.Fatal("safe observations missing") - } - old := f.revision(t) - reset, err := f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *f.input.ModelProvider, Model: "fixture"}) - if err != nil { - t.Fatal(err) - } - snapshot, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - if snapshot.Revision == old || reset.LastUsedAt != nil || reset.LastErrorAt != nil || reset.LastErrorCode != nil { - t.Fatal("identical PUT did not reset") - } - f.observe(t, success, 0) -} -func TestDeploymentObservationSourceAndHistoricalExclusion(t *testing.T) { - f := newProviderObservationFixture(t) - for _, source := range []string{"session", "agent", "unknown", "deployment"} { - input := f.input - input.IdempotencyKey = uuid.NewString() - projection := *input.ExecutionConfiguration - input.ExecutionConfiguration = &projection - input.ModelProviderSource = source - // Historical metadata may name deployment but has no frozen private UUID. - if source == "deployment" { - input.DeploymentProviderRevision = uuid.Nil - } else { - input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`) - if source == "unknown" { - input.ModelProviderSource = "session" - } - } - projection.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} - session, err := f.s.CreateSession(t.Context(), f.tenant, input) - if err != nil { - t.Fatal(source, err) - } - var revision pgtype.UUID - if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&revision); err != nil || revision.Valid { - t.Fatal("non-deployment/historical revision persisted", source, err) - } - // Use a committed fixture outcome directly: hosted dispatch is a separate gate. - id := uuid.NewString() - if _, err = f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at) VALUES($1,$2,'completed','{}',clock_timestamp())", id, session.ID); err != nil { - t.Fatal(err) - } - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, session.ID, id) - if err != nil || n != 0 { - t.Fatal("ineligible source observed", source, err) - } - } -} -func TestDeploymentObservationConcurrentThrottleAndRecovery(t *testing.T) { - f := newProviderObservationFixture(t) - successes := []Turn{} - failures := []Turn{} - for range 8 { - successes = append(successes, f.terminal(t, TurnCompleted, "", "")) - } - for i := range 8 { - code := []string{"authentication_error", "rate_limit_exceeded"}[i%2] - failures = append(failures, f.terminal(t, TurnFailed, "engine_failed", code)) - } - concurrent := func(turns []Turn, want int64) { - t.Helper() - var wg sync.WaitGroup - counts := make(chan int64, len(turns)) - errs := make(chan error, len(turns)) - for _, turn := range turns { - wg.Add(1) - go func() { - defer wg.Done() - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) - counts <- n - errs <- err - }() - } - wg.Wait() - close(counts) - close(errs) - var n int64 - for v := range counts { - n += v - } - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - if n != want { - t.Fatalf("concurrent writes=%d want=%d", n, want) - } - } - concurrent(successes, 1) - concurrent(failures, 1) - concurrent(successes, 1) - concurrent(successes, 0) - concurrent(failures, 0) -} - -// Only the DB clock sample is controlled; execute the actual generated query, -// retaining PostgreSQL locking, predicates, constraints and write count. -type observationClockDB struct { - *pgxpool.Pool - at time.Time - query string -} - -func (db *observationClockDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { - db.query = sql - sql = strings.ReplaceAll(sql, "clock_timestamp()", "'"+db.at.Format(time.RFC3339Nano)+"'::timestamptz") - return db.Pool.Exec(ctx, sql, args...) -} -func TestDeploymentObservationClockBoundariesRollbackAndPlan(t *testing.T) { - f := newProviderObservationFixture(t) - success := f.terminal(t, TurnCompleted, "", "") - failure := f.terminal(t, TurnFailed, "engine_failed", "authentication_error") - base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - db := &observationClockDB{Pool: f.pool} - q := sqlc.New(db) - observe := func(turn Turn, offset time.Duration, want int64) { - t.Helper() - db.at = base.Add(offset) - lookup, _ := turnLookup(f.tenant, f.session.ID, turn.ID) - n, err := q.ObserveDeploymentModelProvider(t.Context(), sqlc.ObserveDeploymentModelProviderParams{TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID}) - if err != nil || n != want { - t.Fatalf("at %s got %d want %d: %v", offset, n, want, err) - } - } - observe(success, 0, 1) - observe(failure, time.Second, 1) - observe(success, 2*time.Second, 1) - observe(failure, 30*time.Second, 0) - observe(failure, 31*time.Second, 1) - observe(success, 31*time.Second, 1) - observe(success, 31*time.Second, 0) - // A new accepted error can recover once even if the clock moves backwards. - observe(failure, 61*time.Second, 1) - observe(success, -time.Second, 1) - observe(success, -time.Second, 0) - observe(success, 29*time.Second, 1) - observe(success, 59*time.Second-time.Microsecond, 0) - observe(success, 59*time.Second, 1) - var actual time.Time - if err := f.pool.QueryRow(t.Context(), "SELECT last_used_at FROM deployment_model_providers").Scan(&actual); err != nil || !actual.Equal(base.Add(59*time.Second)) { - t.Fatal("timestamp was clamped", err) - } - lookup, _ := turnLookup(f.tenant, f.session.ID, success.ID) - // EXPLAIN uses the normal production planner and does not execute the update. - // Tenant/Session and root Turn lookups must constrain the default lookup. - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - rows, err := tx.Query(t.Context(), "EXPLAIN "+db.query, lookup.TenantID, lookup.SessionID, lookup.ID) - if err != nil { - t.Fatal(err) - } - var plan strings.Builder - for rows.Next() { - var line string - _ = rows.Scan(&line) - plan.WriteString(line) - } - rows.Close() - if rows.Err() != nil || (!strings.Contains(plan.String(), "deployment_model_providers_pkey") || (!strings.Contains(plan.String(), "turns_pkey") && !strings.Contains(plan.String(), "turns_session_id_id_key"))) { - t.Fatal("bounded lookup indexes absent", rows.Err(), plan.String()) - } -} -func TestDeploymentObservationReplacementLockRecheckAndTimeout(t *testing.T) { - for _, remove := range []bool{false, true} { - t.Run(fmt.Sprint("delete=", remove), func(t *testing.T) { - f := newProviderObservationFixture(t) - turn := f.terminal(t, TurnCompleted, "", "") - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond) - defer cancel() - if n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID); err == nil || n != 0 { - t.Fatal("locked observation did not time out") - } - done := make(chan int64, 1) - errs := make(chan error, 1) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID) - done <- n - errs <- err - }() - // Verify it actually reached a PostgreSQL lock wait, rather than racing on - // goroutine scheduling, before replacing/deleting the selected revision. - deadline := time.Now().Add(time.Second) - waiting := false - for time.Now().Before(deadline) { - if err = f.pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%ObserveDeploymentModelProvider%')").Scan(&waiting); err != nil { - t.Fatal(err) - } - if waiting { - break - } - time.Sleep(time.Millisecond) - } - if !waiting { - t.Fatal("observation never waited on row lock") - } - if remove { - _, err = tx.Exec(t.Context(), "DELETE FROM deployment_model_providers") - } else { - _, err = tx.Exec(t.Context(), "UPDATE deployment_model_providers SET revision=$1", uuid.New()) - } - if err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if n := <-done; n != 0 { - t.Fatal("stale revision updated replacement", n) - } - if err = <-errs; err != nil { - t.Fatal(err) - } - }) - } -} -func TestDeploymentObservationMigrationRoundTrip(t *testing.T) { - f := newProviderObservationFixture(t) - old := f.revision(t) - var secret []byte - if err := f.pool.QueryRow(t.Context(), "SELECT encrypted_config FROM deployment_model_providers").Scan(&secret); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile("../../migrations/000084_deployment_provider_observations.sql") - if err != nil { - t.Fatal(err) - } - parts := strings.Split(string(raw), "-- +goose Down") - if _, err = f.pool.Exec(t.Context(), parts[1]); err != nil { - t.Fatal(err) - } - if _, err = f.pool.Exec(t.Context(), parts[0]); err != nil { - t.Fatal(err) - } - var revision uuid.UUID - var frozen pgtype.UUID - var preserved []byte - if err = f.pool.QueryRow(t.Context(), "SELECT revision,encrypted_config FROM deployment_model_providers").Scan(&revision, &preserved); err != nil { - t.Fatal(err) - } - if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&frozen); err != nil { - t.Fatal(err) - } - if revision == old || frozen.Valid || string(secret) != string(preserved) { - t.Fatal("migration recreated historical identity or changed ciphertext") - } - frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) - if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { - t.Fatal("migration/replacement changed Session bundle", err) - } - f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) -} diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go deleted file mode 100644 index f14e7954e..000000000 --- a/services/agents-api/internal/store/environment_executor_management.go +++ /dev/null @@ -1,231 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// ExecutorCredential is the metadata of one Environment executor credential. -// Its secret is returned only when issued or rotated. -type ExecutorCredential struct { - KeyID string `json:"key_id" format:"uuid"` - CreatedAt time.Time `json:"created_at"` - RevokedAt *time.Time `json:"revoked_at" extensions:"x-nullable"` -} - -// The Project* methods serve Core-key executor credential management. project -// is the Project's own principal, which becomes the credential's execution -// principal; the target must be a self_hosted Environment of that Project whose -// Session is not deleted, and only credentials restricted to it are managed. - -// ExecutorConnectionState is an internal durable observation, never a wire payload. -// In particular the current credential digest must not be serialized. -type ExecutorConnectionState struct { - DeviceID string `json:"-"` - BoundKeyID *string `json:"-"` - EnrolledAt *time.Time `json:"-"` - LastSeenAt *time.Time `json:"-"` - CredentialHash string `json:"-"` - EnvironmentStatus string `json:"-"` -} - -type ExecutorCredentialState struct { - EnvironmentID string `json:"-"` - Credentials []ExecutorCredential - Connection ExecutorConnectionState -} - -func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project identity.Principal, environment string) ([]ExecutorCredential, error) { - state, err := s.ProjectExecutorCredentialState(ctx, project, environment) - return state.Credentials, err -} - -// ProjectExecutorCredentialState reads list metadata and binding facts in one -// read-only snapshot. The snapshot ends before any live peer/authority observation. -func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project identity.Principal, environment string) (ExecutorCredentialState, error) { - if err := project.Validate(); err != nil { - return ExecutorCredentialState{}, ErrInvalidInput - } - tenant, err := parseID(project.TenantID) - if err != nil { - return ExecutorCredentialState{}, err - } - environmentID := parsePathID(environment) - result := ExecutorCredentialState{Credentials: []ExecutorCredential{}} - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.GetEnvironmentExecutorConnection(ctx, sqlc.GetEnvironmentExecutorConnectionParams{EnvironmentID: environmentID, TenantID: tenant}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - result.EnvironmentID = uuid.UUID(environmentID.Bytes).String() - result.Connection.EnvironmentStatus = row.EnvironmentStatus - if row.DeviceID.Valid { - result.Connection.DeviceID = uuid.UUID(row.DeviceID.Bytes).String() - } - if row.ExecutorKeyID.Valid { - key := uuid.UUID(row.ExecutorKeyID.Bytes).String() - result.Connection.BoundKeyID = &key - } - if row.EnrolledAt.Valid { - at := row.EnrolledAt.Time - result.Connection.EnrolledAt = &at - } - if row.LastSeenAt.Valid { - at := row.LastSeenAt.Time - result.Connection.LastSeenAt = &at - } - if row.CredentialHash.Valid { - result.Connection.CredentialHash = row.CredentialHash.String - } - rows, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ - TenantID: tenant, EnvironmentID: environmentID, - SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, - }) - if err != nil { - return err - } - for _, row := range rows { - credential := ExecutorCredential{KeyID: uuid.UUID(row.KeyID.Bytes).String(), CreatedAt: row.CreatedAt.Time} - if row.RevokedAt.Valid { - revoked := row.RevokedAt.Time - credential.RevokedAt = &revoked - } - result.Credentials = append(result.Credentials, credential) - } - return nil - }) - return result, err -} - -// IssueProjectExecutorCredential issues a new key or, with rotate, replaces the -// secret of an existing key restricted to the Environment. An archived Project -// gets neither (ErrProjectArchived). The administrator audit entry commits in -// the same transaction and never contains the secret. -func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { - // The target is checked first, then the archived Project, then the key. - if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { - return IssuedExecutorCredential{}, err - } - if err := activeProject(ctx, s.queries, project); err != nil { - return IssuedExecutorCredential{}, err - } - if !rotate { - return s.issueExecutorCredential(ctx, project, keyID, environment, activeProjectAudit(project, "issue", keyID)) - } - if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { - return IssuedExecutorCredential{}, err - } - return s.rotateExecutorCredential(ctx, project, keyID, activeProjectAudit(project, "rotate", keyID)) -} - -// RevokeProjectExecutorCredential is idempotent and also works in an archived -// Project; each successful request is audited. -func (s *Store) RevokeProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string) error { - if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { - return err - } - if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { - return err - } - tenant, id, err := executorCredentialIdentity(project, keyID) - if err != nil { - return err - } - record := executorCredentialAudit(project, "revoke", keyID) - return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - n, err := q.RevokeExecutorCredential(ctx, sqlc.RevokeExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}}) - if err != nil { - return err - } - if n == 0 { - return ErrNotFound - } - return record(ctx, q) - }) -} - -func executorCredentialAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { - return func(ctx context.Context, q *sqlc.Queries) error { - return recordAdminMutation(ctx, q, project.TenantID, action, "executor_credential", keyID) - } -} - -// activeProjectAudit repeats the archive check in the writing transaction. It -// share-locks the Project, which archiving updates, so an issuance or rotation -// either commits before the archive or sees it and fails. -func activeProjectAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { - audit := executorCredentialAudit(project, action, keyID) - return func(ctx context.Context, q *sqlc.Queries) error { - if err := activeProject(ctx, q, project); err != nil { - return err - } - return audit(ctx, q) - } -} - -// activeProject returns ErrProjectArchived for an archived Project. -func activeProject(ctx context.Context, q *sqlc.Queries, project identity.Principal) error { - tenant, err := parseID(project.TenantID) - if err != nil { - return err - } - row, err := q.LockProjectByTenant(ctx, tenant) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if row.ArchivedAt.Valid { - return ErrProjectArchived - } - return nil -} - -func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { - if err := principal.Validate(); err != nil { - return ErrInvalidInput - } - owned, err := s.GetEnvironment(ctx, principal.TenantID, environment) - if err != nil { - return err - } - var configuration struct { - Type string `json:"type"` - } - if json.Unmarshal(owned.Configuration, &configuration) != nil || configuration.Type != "self_hosted" { - return ErrNotFound - } - return nil -} - -func (s *Store) exactExecutorRestriction(ctx context.Context, principal identity.Principal, environment, keyID string) error { - tenant, id, err := executorCredentialIdentity(principal, keyID) - if err != nil { - return err - } - want := parsePathID(environment) - actual, err := s.executorCredentialRestriction(ctx, principal, tenant, id) - if err != nil { - return err - } - // Restrictions and principals are immutable, so checking before the - // rotation or revocation transaction cannot authorize a different target. - if !actual.Valid || actual != want { - return ErrNotFound - } - return nil -} diff --git a/services/agents-api/internal/store/environment_installation.go b/services/agents-api/internal/store/environment_installation.go deleted file mode 100644 index b5639a5d2..000000000 --- a/services/agents-api/internal/store/environment_installation.go +++ /dev/null @@ -1,119 +0,0 @@ -package store - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -var ErrInstallationAuthorization = errors.New("installation authorization is invalid or expired; obtain a new command from the Session") - -// InstallationAuthorization permits claiming one Environment's connect-only key. -// The Environment UUID is reserved as that key's ID. Reissuing an authorization -// never rotates or revives the key, and a retry must prove the same local secret. -type InstallationAuthorization struct { - Principal identity.Principal `json:"principal"` - Environment string `json:"environment_id"` - Version string `json:"version"` - ExpiresAt int64 `json:"expires_at"` -} - -func (s *Store) AuthorizeEnvironmentInstallation(ctx context.Context, principal identity.Principal, environment, version string) (string, int64, error) { - if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { - return "", 0, err - } - if err := activeProject(ctx, s.queries, principal); err != nil { - return "", 0, err - } - claim := InstallationAuthorization{Principal: principal, Environment: environment, Version: version, ExpiresAt: time.Now().Add(30 * time.Minute).Unix()} - payload, err := json.Marshal(claim) - if err != nil { - return "", 0, err - } - encoded := base64.RawURLEncoding.EncodeToString(payload) - signature, err := s.credentialCipher.Fingerprint("environment-installation", encoded) - if err != nil { - return "", 0, err - } - return encoded + "." + signature, claim.ExpiresAt, nil -} - -func (s *Store) ValidateEnvironmentInstallation(ctx context.Context, token, version string) (InstallationAuthorization, error) { - var claim InstallationAuthorization - encoded, signature, ok := strings.Cut(token, ".") - if !ok || len(token) > 4096 { - return claim, ErrInstallationAuthorization - } - want, err := s.credentialCipher.Fingerprint("environment-installation", encoded) - if err != nil || !hmac.Equal([]byte(want), []byte(signature)) { - return claim, ErrInstallationAuthorization - } - payload, err := base64.RawURLEncoding.DecodeString(encoded) - if err != nil || json.Unmarshal(payload, &claim) != nil || claim.Version != version || claim.ExpiresAt <= time.Now().Unix() { - return InstallationAuthorization{}, ErrInstallationAuthorization - } - if err := s.selfHostedExecutorTarget(ctx, claim.Principal, claim.Environment); err != nil { - return InstallationAuthorization{}, ErrInstallationAuthorization - } - if err := activeProject(ctx, s.queries, claim.Principal); err != nil { - return InstallationAuthorization{}, ErrInstallationAuthorization - } - return claim, nil -} - -// ClaimEnvironmentInstallation stores only the digest of a secret generated and -// persisted by the installer before this request. A lost HTTP response is safe -// to retry; another machine or a revoked/rotated key cannot claim it again. -func (s *Store) ClaimEnvironmentInstallation(ctx context.Context, token, version, secret string) error { - claim, err := s.ValidateEnvironmentInstallation(ctx, token, version) - if err != nil { - return err - } - decoded, err := base64.RawURLEncoding.DecodeString(secret) - if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != secret { - return ErrInvalidInput - } - principal := claim.Principal - tenant, id, err := executorCredentialIdentity(principal, claim.Environment) - if err != nil { - return err - } - hash := sha256.Sum256([]byte(secret)) - digest := hex.EncodeToString(hash[:]) - return s.withExecutorCredentialTarget(ctx, principal, id, func(ctx context.Context, q *sqlc.Queries) error { - if err := activeProject(ctx, q, principal); err != nil { - return err - } - keys, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{TenantID: tenant, EnvironmentID: id, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}}) - if err != nil { - return err - } - if len(keys) > 0 { - // Existing operator-issued keys must not be replaced by onboarding. - if len(keys) != 1 || keys[0].KeyID != id || keys[0].RevokedAt.Valid { - return ErrExecutorCredentialExists - } - _, err := q.AuthenticateEnvironmentExecutor(ctx, sqlc.AuthenticateEnvironmentExecutorParams{EnvironmentID: id, TokenSha256: digest}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrExecutorCredentialExists - } - return err - } - _, err = q.IssueExecutorCredential(ctx, sqlc.IssueExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, EnvironmentID: id, TokenSha256: digest}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrExecutorCredentialExists - } - return err - }) -} diff --git a/services/agents-api/internal/store/environment_installation_test.go b/services/agents-api/internal/store/environment_installation_test.go deleted file mode 100644 index a8d7f77f7..000000000 --- a/services/agents-api/internal/store/environment_installation_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package store - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - ctx := t.Context() - project := createTestProject(t, s) - binding, err := s.GetProject(ctx, project.ID) - if err != nil { - t.Fatal(err) - } - p := binding.Principal - input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") - input.Creator = p.Subject() - session, err := s.CreateSession(ctx, p.TenantID, input) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) - if err != nil { - t.Fatal(err) - } - token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, p, environment.ID, "build") - if err != nil || expires <= time.Now().Unix() || expires > time.Now().Add(31*time.Minute).Unix() { - t.Fatal("authorization", err) - } - for _, pair := range [][2]string{{token + "x", "build"}, {token, "other-build"}, {"", "build"}} { - if _, err := s.ValidateEnvironmentInstallation(ctx, pair[0], pair[1]); !errors.Is(err, ErrInstallationAuthorization) { - t.Fatal("accepted invalid authorization", err) - } - } - payload, _, _ := strings.Cut(token, ".") - raw, _ := base64.RawURLEncoding.DecodeString(payload) - var expired InstallationAuthorization - _ = json.Unmarshal(raw, &expired) - expired.ExpiresAt = time.Now().Add(-time.Second).Unix() - raw, _ = json.Marshal(expired) - payload = base64.RawURLEncoding.EncodeToString(raw) - signature, _ := cipher.Fingerprint("environment-installation", payload) - if _, err := s.ValidateEnvironmentInstallation(ctx, payload+"."+signature, "build"); !errors.Is(err, ErrInstallationAuthorization) { - t.Fatal("accepted expired grant", err) - } - one, _, _ := newExecutorSecret() - two, _, _ := newExecutorSecret() - secrets := []string{one, two} - results := make([]error, 2) - var wg sync.WaitGroup - for i := range secrets { - wg.Add(1) - go func() { defer wg.Done(); results[i] = s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[i]) }() - } - wg.Wait() - winner := -1 - for i, err := range results { - if err == nil { - if winner >= 0 { - t.Fatal("two machines claimed one Environment") - } - winner = i - } else if !errors.Is(err, ErrExecutorCredentialExists) { - t.Fatal(err) - } - } - if winner < 0 { - t.Fatal("no claim succeeded") - } - if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); err != nil { - t.Fatal("lost-response retry", err) - } - if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(secrets[winner])); err != nil { - t.Fatal(err) - } - if err := s.RevokeExecutorCredential(ctx, p, environment.ID); err != nil { - t.Fatal(err) - } - if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); !errors.Is(err, ErrExecutorCredentialExists) { - t.Fatal("revoked key resurrected", err) - } - if err := s.DeleteSession(ctx, p.TenantID, session.ID); err != nil { - t.Fatal(err) - } - if _, err := s.ValidateEnvironmentInstallation(ctx, token, "build"); !errors.Is(err, ErrInstallationAuthorization) { - t.Fatal("deleted Session grant accepted", err) - } -} diff --git a/services/agents-api/internal/store/environment_plugins.go b/services/agents-api/internal/store/environment_plugins.go deleted file mode 100644 index fdc8635e5..000000000 --- a/services/agents-api/internal/store/environment_plugins.go +++ /dev/null @@ -1,57 +0,0 @@ -package store - -import ( - "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" -) - -const MaxPluginsArchiveBytes = 10 << 20 - -// EnvironmentPlugin separates safe identity from confidential immutable input. -type EnvironmentPlugin struct { - Metadata agentplugin.Metadata `json:"metadata"` - Archive []byte `json:"archive"` -} - -func ValidateEnvironmentPlugins(plugins []EnvironmentPlugin) error { - if len(plugins) > 50 { - return ErrInvalidInput - } - seen := map[string]bool{} - compressed, expanded := 0, 0 - for _, plugin := range plugins { - compressed += len(plugin.Archive) - if compressed > MaxPluginsArchiveBytes || seen[plugin.Metadata.Name] { - return ErrInvalidInput - } - seen[plugin.Metadata.Name] = true - bundle, err := agentplugin.Read(plugin.Archive, plugin.Metadata) - if err != nil { - return ErrInvalidInput - } - for _, file := range bundle.Files { - expanded += len(file.Data) - } - if expanded > 50<<20 { - return ErrInvalidInput - } - } - return nil -} - -func (s EnvironmentSetup) PluginMetadata() []agentplugin.Metadata { - result := make([]agentplugin.Metadata, 0, len(s.Plugins)) - for _, plugin := range s.Plugins { - result = append(result, plugin.Metadata) - } - return result -} - -func (s *Store) sealTemplatePlugins(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { - metadata, err := json.Marshal(setup.PluginMetadata()) - if err != nil { - return nil, nil, err - } - contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "plugins", setup.Plugins, len(setup.Plugins) == 0) - return metadata, contents, err -} diff --git a/services/agents-api/internal/store/environment_plugins_test.go b/services/agents-api/internal/store/environment_plugins_test.go deleted file mode 100644 index f6d109a14..000000000 --- a/services/agents-api/internal/store/environment_plugins_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package store - -import ( - "archive/zip" - "bytes" - "encoding/json" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{23}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - for path, body := range map[string]string{ - ".codex-plugin/plugin.json": `{"name":"plugin-proof","description":"A proof.","skills":"./skills"}`, - "skills/proof/SKILL.md": "---\nname: proof\ndescription: A proof.\n---\nplugin-private-canary", - "shared/data.txt": "plugin-private-resource", - } { - f, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/" + path, Method: zip.Store}) - if err != nil { - t.Fatal(err) - } - if _, err = f.Write([]byte(body)); err != nil { - t.Fatal(err) - } - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - setup := EnvironmentSetup{Plugins: []EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive.Bytes()}}, CapabilityDirectories: []string{"/workspace/generated"}} - tenant, foreign := uuid.NewString(), uuid.NewString() - template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true, Initialization: setup}) - if err != nil { - t.Fatal(err) - } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Plugins) != 1 || !public.Initialization.Empty() || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { - t.Fatal("safe metadata without decryption", err) - } - page, err := New(pool).ListEnvironmentTemplates(t.Context(), tenant, "", 20, false) - if err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { - t.Fatal("list", err) - } - var metadata, encrypted []byte - if err = pool.QueryRow(t.Context(), "SELECT plugins,plugin_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || len(encrypted) == 0 || bytes.Contains(metadata, []byte("private")) || bytes.Contains(encrypted, []byte("private")) { - t.Fatal("plaintext storage", err) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(resolved.Initialization.Plugins, setup.Plugins) { - t.Fatal("resolution", err) - } - for _, read := range []func() error{ - func() error { _, e := s.GetEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, - func() error { _, _, e := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, - func() error { - _, e := s.UpdateEnvironmentTemplate(t.Context(), foreign, template.ID, EnvironmentTemplateInput{SetPlugins: true}) - return e - }, - } { - if err := read(); !errors.Is(err, ErrNotFound) { - t.Fatal("tenant isolation", err) - } - } - request := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} - session, err := s.CreateSession(t.Context(), tenant, request) - if err != nil { - t.Fatal(err) - } - var cfg struct { - Environment struct { - Plugins []agentplugin.Metadata `json:"plugins"` - Directories []string `json:"capability_directories"` - } `json:"environment"` - } - if err = json.Unmarshal(session.Configuration, &cfg); err != nil || len(cfg.Environment.Plugins) != 1 || !reflect.DeepEqual(cfg.Environment.Directories, setup.CapabilityDirectories) { - t.Fatal("frozen public metadata", err) - } - name := "renamed" - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { - t.Fatal(err) - } - preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(preserved.Initialization.Plugins, setup.Plugins) || !reflect.DeepEqual(preserved.Initialization.CapabilityDirectories, setup.CapabilityDirectories) { - t.Fatal("unrelated update changed installation", err) - } - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true}); err != nil { - t.Fatal(err) - } - cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(cleared.Initialization.Plugins) != 0 || len(cleared.CapabilityDirectories) != 0 { - t.Fatal("clear", err) - } - if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } - frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(frozen.Plugins, setup.Plugins) || !reflect.DeepEqual(frozen.CapabilityDirectories, setup.CapabilityDirectories) { - t.Fatal("frozen snapshot changed", err) - } - retry, err := s.CreateSession(t.Context(), tenant, request) - if err != nil || retry.ID != session.ID { - t.Fatal("retry", err) - } - if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign snapshot", err) - } -} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go deleted file mode 100644 index bd4afb903..000000000 --- a/services/agents-api/internal/store/environment_setup.go +++ /dev/null @@ -1,174 +0,0 @@ -package store - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "path" - "regexp" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// EnvironmentSetup is confidential input, never ordinary resource metadata. -// Core freezes it once; the common Runtime initializer executes it in order. -type EnvironmentSetup struct { - Env map[string]string `json:"env,omitempty"` - Commands []SetupCommand `json:"setup_commands,omitempty"` - Packages v1.EnvironmentPackages `json:"packages"` - Skills []EnvironmentSkill `json:"skills,omitempty"` - Plugins []EnvironmentPlugin `json:"plugins,omitempty"` - CapabilityDirectories []string `json:"capability_directories,omitempty"` -} - -type SetupCommand struct { - Command string `json:"command"` - CWD string `json:"cwd,omitempty"` -} - -var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -func (s EnvironmentSetup) Empty() bool { - return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 -} - -func (s EnvironmentSetup) Validate() error { - return s.validate(false) -} - -func (s EnvironmentSetup) validate(installed bool) error { - if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateSourceDirectories(s.CapabilityDirectories) != nil { - return ErrInvalidInput - } - ordinary := s - ordinary.Skills = nil - ordinary.Plugins = nil - raw, err := json.Marshal(ordinary) - if err != nil || len(raw) > 512*1024 { - return ErrInvalidInput - } - for name, value := range s.Env { - // The first three reservations are explicitly part of the public guide; - // OAC_* identifies the actual deployment authority and binding. - if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "OAC_") || strings.ContainsRune(value, 0) { - return ErrInvalidInput - } - } - for _, command := range s.Commands { - if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { - return ErrInvalidInput - } - } - for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { - for _, item := range packages { - if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { - return ErrInvalidInput - } - } - } - return nil -} - -func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { - if empty { - return nil, nil - } - plaintext, err := json.Marshal(input) - if err != nil { - return nil, err - } - return s.credentialCipher.SealEnvironmentSetup(plaintext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) -} - -func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphertext []byte, output any) error { - if len(ciphertext) == 0 { - return nil - } - plaintext, err := s.credentialCipher.OpenEnvironmentSetup(ciphertext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) - if err != nil { - return err - } - if decodeSetupJSON(plaintext, output) != nil { - return ErrInvalidInput - } - return nil -} - -func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { - if err := setup.validate(true); err != nil { - return err - } - if setup.Empty() { - return nil - } - owner, err := parseID(tenant) - if err != nil { - return err - } - encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup, false) - if err != nil { - return err - } - return q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: session, Contents: encrypted}) -} - -func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (EnvironmentSetup, error) { - var result EnvironmentSetup - lookup, err := deviceLookup(tenant, session) - if err != nil { - return result, ErrNotFound - } - encrypted, err := s.queries.GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) - if errors.Is(err, pgx.ErrNoRows) { - return result, ErrNotFound - } - if err != nil { - return result, err - } - if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { - return result, err - } - return result, result.validate(true) -} - -func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { - result := s.Packages - if result.NPM == nil { - result.NPM = []string{} - } - if result.Python == nil { - result.Python = []string{} - } - return result -} - -func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, []byte, error) { - packages, err := json.Marshal(setup.PackageMetadata()) - if err != nil { - return nil, nil, nil, err - } - env, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "env", setup.Env, len(setup.Env) == 0) - if err != nil { - return nil, nil, nil, err - } - commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) - return packages, env, commands, err -} - -// decodeSetupJSON rejects removed configuration fields instead of silently dropping them. -func decodeSetupJSON(data []byte, output any) error { - if !json.Valid(data) { - return ErrInvalidInput - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - return decoder.Decode(output) -} diff --git a/services/agents-api/internal/store/environment_skills.go b/services/agents-api/internal/store/environment_skills.go deleted file mode 100644 index 0bf74ffc1..000000000 --- a/services/agents-api/internal/store/environment_skills.go +++ /dev/null @@ -1,116 +0,0 @@ -package store - -import ( - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" -) - -// EnvironmentSkillMetadata is either template intent or concrete installed metadata. -// References acquire their name, description and concrete version at Session commit. -type EnvironmentSkillMetadata struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - SkillID string `json:"skill_id,omitempty"` - Version string `json:"version,omitempty"` -} - -// EnvironmentSkill keeps confidential content separate from public metadata. -// A template reference has no archive; a committed Session always has frozen bytes. -type EnvironmentSkill struct { - Metadata EnvironmentSkillMetadata `json:"metadata"` - Archive []byte `json:"archive,omitempty"` -} - -// InstallationMetadata removes public reference identity at the installer boundary. -func (s EnvironmentSkill) InstallationMetadata() agentskill.Metadata { - return agentskill.Metadata{Type: "inline", Name: s.Metadata.Name, Description: s.Metadata.Description} -} - -const MaxSkillsArchiveBytes = 10 << 20 - -func ValidateEnvironmentSkills(skills []EnvironmentSkill) error { - return validateEnvironmentSkills(skills, false) -} - -func ValidateInstalledSkillMetadata(metadata EnvironmentSkillMetadata) error { - if metadata.Name == "" || metadata.Description == "" { - return ErrInvalidInput - } - switch metadata.Type { - case "inline": - if metadata.SkillID != "" || metadata.Version != "" { - return ErrInvalidInput - } - case "skill_reference": - if metadata.SkillID == "" { - return ErrInvalidInput - } - if _, err := skillVersionNumber(metadata.Version); err != nil { - return err - } - default: - return ErrInvalidInput - } - return nil -} - -func validateEnvironmentSkills(skills []EnvironmentSkill, installed bool) error { - if len(skills) > 50 { - return ErrInvalidInput - } - seen := map[string]bool{} - total := 0 - expanded := 0 - for _, skill := range skills { - if !installed && skill.Metadata.Type == "skill_reference" { - m := skill.Metadata - if m.SkillID == "" || len(m.SkillID) > 256 || m.Name != "" || m.Description != "" || len(skill.Archive) != 0 { - return ErrInvalidInput - } - if m.Version != "" && m.Version != "latest" { - if _, err := skillVersionNumber(m.Version); err != nil { - return err - } - } - continue - } - if err := ValidateInstalledSkillMetadata(skill.Metadata); err != nil { - return err - } - total += len(skill.Archive) - if total > MaxSkillsArchiveBytes || seen[skill.Metadata.Name] { - return ErrInvalidInput - } - seen[skill.Metadata.Name] = true - files, err := agentskill.Read(skill.Archive, skill.InstallationMetadata()) - if err != nil { - return ErrInvalidInput - } - for _, file := range files { - expanded += len(file.Data) - } - if expanded > 50<<20 { - return ErrInvalidInput - } - } - return nil -} - -func (s EnvironmentSetup) SkillMetadata() []EnvironmentSkillMetadata { - result := make([]EnvironmentSkillMetadata, 0, len(s.Skills)) - for _, skill := range s.Skills { - result = append(result, skill.Metadata) - } - return result -} - -func (s *Store) sealTemplateSkills(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { - metadata, err := json.Marshal(setup.SkillMetadata()) - if err != nil { - return nil, nil, err - } - contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "skills", setup.Skills, len(setup.Skills) == 0) - return metadata, contents, err -} diff --git a/services/agents-api/internal/store/environment_skills_test.go b/services/agents-api/internal/store/environment_skills_test.go deleted file mode 100644 index badd2c45e..000000000 --- a/services/agents-api/internal/store/environment_skills_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package store - -import ( - "archive/zip" - "bytes" - "encoding/json" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - header := &zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store} - file, err := writer.CreateHeader(header) - if err != nil { - t.Fatal(err) - } - if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\nprivate-skill-canary")); err != nil { - t.Fatal(err) - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - setup := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} - tenant, foreign := uuid.NewString(), uuid.NewString() - template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) - if err != nil { - t.Fatal(err) - } - public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Skills) != 1 || !public.Initialization.Empty() { - t.Fatal("public metadata", err) - } - var metadata, encrypted []byte - if err = pool.QueryRow(t.Context(), "SELECT skills,skill_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || bytes.Contains(metadata, []byte("canary")) || bytes.Contains(encrypted, []byte("canary")) { - t.Fatal("plaintext storage", err) - } - resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(resolved.Initialization.Skills, setup.Skills) { - t.Fatal("resolution", err) - } - if _, _, err = s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("tenant isolation", err) - } - session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization}) - if err != nil { - t.Fatal(err) - } - name := "renamed" - if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { - t.Fatal(err) - } - preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || !reflect.DeepEqual(preserved.Initialization.Skills, setup.Skills) { - t.Fatal("unrelated update", err) - } - cleared, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetSkills: true}) - if err != nil || len(cleared.Skills) != 0 { - t.Fatal("clearing", err) - } - if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { - t.Fatal(err) - } - frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(frozen.Skills, setup.Skills) { - t.Fatal("frozen content changed", err) - } - if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign content", err) - } -} diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go deleted file mode 100644 index d12f0cfae..000000000 --- a/services/agents-api/internal/store/environment_templates.go +++ /dev/null @@ -1,243 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "time" - "unicode/utf8" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// EnvironmentTemplate is configuration ownership, independent of provider images. - -type EnvironmentTemplate struct { - Plugins []agentplugin.Metadata - CapabilityDirectories []string - Skills []EnvironmentSkillMetadata - Packages v1.EnvironmentPackages - Initialization EnvironmentSetup - Files []InitialFileMetadata - ID string - Name *string - NetworkAccess string - AllowedDomains []string - CreatedAt time.Time - UpdatedAt time.Time -} - -type EnvironmentTemplateInput struct { - Initialization EnvironmentSetup - SetEnv, SetSetup, SetPackages, SetSkills, SetPlugins, SetDirectories bool - Files []InitialFile - SetFiles bool - Name *string - SetName bool - NetworkAccess string - AllowedDomains []string - SetNetwork bool -} - -func (in EnvironmentTemplateInput) valid() bool { - return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && - (!in.SetNetwork || (agentnetwork.Policy{Access: in.NetworkAccess, AllowedDomains: in.AllowedDomains}).Validate() == nil) -} - -type templateMetadataRow sqlc.GetEnvironmentTemplateRow - -func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, error) { - if errors.Is(err, pgx.ErrNoRows) { - return EnvironmentTemplate{}, ErrNotFound - } - if err != nil { - return EnvironmentTemplate{}, err - } - result := EnvironmentTemplate{CapabilityDirectories: append([]string{}, row.CapabilityDirectories...), ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} - if row.Name.Valid { - result.Name = &row.Name.String - } - if json.Unmarshal(row.Files, &result.Files) != nil || decodeSetupJSON(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { - return EnvironmentTemplate{}, ErrInvalidInput - } - return result, nil -} - -func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { - if !in.SetNetwork { - in.NetworkAccess = "enabled" - in.AllowedDomains = nil - in.SetNetwork = true - } - if !in.valid() { - return EnvironmentTemplate{}, ErrInvalidInput - } - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - var name pgtype.Text - if in.Name != nil { - name = pgtype.Text{String: *in.Name, Valid: true} - } - id := uuid.New() - metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), id.String(), in.Files) - if err != nil { - return EnvironmentTemplate{}, err - } - packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - var result EnvironmentTemplate - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - result, err = templateFromRow(templateMetadataRow(row), err) - if err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "create", "environment_template", result.ID, "", AuditResource{Type: "environment_template", ID: result.ID}) - }) - return result, err -} - -func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - id, err := parseID(templateID) - if err != nil { - return EnvironmentTemplate{}, ErrNotFound - } - row, err := s.queries.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: id}) - return templateFromRow(templateMetadataRow(row), err) -} - -// Each supplied field replaces atomically, preserving concurrent unrelated updates. -func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templateID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { - if !in.valid() { - return EnvironmentTemplate{}, ErrInvalidInput - } - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplate{}, err - } - // Sealing runs before the update lookup, so a malformed ID must take the same path. - id := parsePathID(templateID) - var name pgtype.Text - if in.Name != nil { - name = pgtype.Text{String: *in.Name, Valid: true} - } - metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Files) - if err != nil { - return EnvironmentTemplate{}, err - } - packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) - if err != nil { - return EnvironmentTemplate{}, err - } - var result EnvironmentTemplate - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - result, err = templateFromRow(templateMetadataRow(row), err) - if err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "update", "environment_template", result.ID, "") - }) - return result, err -} - -func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { - tenant, err := parseID(tenantID) - if err != nil { - return "", err - } - id, err := parseID(templateID) - if err != nil { - return "", ErrNotFound - } - var deletedID string - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - result, err := q.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) - if err != nil { - return err - } - deletedID = uuid.UUID(result.Bytes).String() - return recordWriteAudit(ctx, q, tenantID, "delete", "environment_template", deletedID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", err - } - return deletedID, nil -} - -type EnvironmentTemplatePage struct { - Templates []EnvironmentTemplate - HasMore bool -} - -func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (EnvironmentTemplatePage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return EnvironmentTemplatePage{}, err - } - if limit < 1 || limit > 100 { - return EnvironmentTemplatePage{}, ErrInvalidInput - } - params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} - if cursor != "" { - after, err := s.GetEnvironmentTemplate(ctx, tenantID, lookupCursor(cursor)) - if err != nil { - return EnvironmentTemplatePage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - params.AfterID, _ = parseID(after.ID) - } - rows, err := s.queries.ListEnvironmentTemplates(ctx, params) - if err != nil { - return EnvironmentTemplatePage{}, err - } - page := EnvironmentTemplatePage{Templates: make([]EnvironmentTemplate, 0, min(limit, len(rows))), HasMore: len(rows) > limit} - if len(rows) > limit { - rows = rows[:limit] - } - for _, row := range rows { - value, err := templateFromRow(templateMetadataRow(row), nil) - if err != nil { - return EnvironmentTemplatePage{}, err - } - page.Templates = append(page.Templates, value) - } - return page, nil -} diff --git a/services/agents-api/internal/store/environments.go b/services/agents-api/internal/store/environments.go deleted file mode 100644 index 451861722..000000000 --- a/services/agents-api/internal/store/environments.go +++ /dev/null @@ -1,89 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -// Environment retains execution ownership; its configuration is an internal snapshot, not a public response. -type Environment struct { - Initialization string - ID string - SessionID string - TenantID string - Status string - CreatedAt time.Time - Configuration json.RawMessage -} - -func createSessionEnvironment(ctx context.Context, q *sqlc.Queries, session sqlc.Session) error { - var snapshot struct { - Environment *struct { - Type string `json:"type"` - } `json:"environment"` - } - if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { - return fmt.Errorf("%w: invalid environment configuration", ErrInvalidInput) - } - if snapshot.Environment == nil || snapshot.Environment.Type == "none" { - return nil - } - switch snapshot.Environment.Type { - case "self_hosted", "openai_hosted": - return q.CreateEnvironment(ctx, sqlc.CreateEnvironmentParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session.ID, - }) - default: - return fmt.Errorf("%w: unsupported environment type", ErrInvalidInput) - } -} - -func (s *Store) GetEnvironment(ctx context.Context, tenantID, environmentID string) (Environment, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Environment{}, err - } - id := parsePathID(environmentID) - row, err := s.queries.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenant, ID: id}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) -} - -func (s *Store) GetSessionEnvironment(ctx context.Context, tenantID, sessionID string) (Environment, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Environment{}, err - } - id, err := parseID(sessionID) - if err != nil { - return Environment{}, err - } - row, err := s.queries.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: id}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) -} - -func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration []byte, err error) (Environment, error) { - if errors.Is(err, pgx.ErrNoRows) { - return Environment{}, ErrNotFound - } - if err != nil { - return Environment{}, fmt.Errorf("get environment: %w", err) - } - configuration, err = canonicalJSONObject(configuration) - if err != nil { - return Environment{}, fmt.Errorf("decode environment configuration: %w", err) - } - return Environment{ - ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), - TenantID: uuid.UUID(tenant.Bytes).String(), Status: row.Status, - Initialization: row.Initialization, CreatedAt: row.CreatedAt.Time, Configuration: configuration, - }, nil -} diff --git a/services/agents-api/internal/store/export_test.go b/services/agents-api/internal/store/export_test.go deleted file mode 100644 index 08a6df914..000000000 --- a/services/agents-api/internal/store/export_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package store - -import ( - "bytes" - "context" - "encoding/json" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/jackc/pgx/v5/pgxpool" - "testing" -) - -func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) } - -var fixtureCipher, _ = credentialcrypto.New(bytes.Repeat([]byte{61}, 32)) - -// FixtureCipher is the credential key of NewModelTestStore, for reopened stores. -func FixtureCipher() *credentialcrypto.Cipher { return fixtureCipher } - -// NewModelTestStore is NewTestStore with a fixture credential key, so Sessions -// can freeze a model provider. -func NewModelTestStore(t *testing.T) (*Store, *pgxpool.Pool) { - _, pool := testStore(t) - return NewWithCredentialCipher(pool, fixtureCipher), pool -} - -// FixtureModelProvider is a valid bundle for the harness. Hosted and self-hosted -// Sessions cannot run without one, so fixtures supply it instead of relaxing -// that check. -func FixtureModelProvider(harness string) *v1.ModelProviderInput { - if harness == "codex" { - return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} - } - return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} -} - -// WithFixtureModelProvider adds the fixture provider, as a Session-supplied -// bundle, to a hosted or self-hosted creation that has none. The store must -// have a credential key; other inputs are returned unchanged. -func WithFixtureModelProvider(input CreateSessionInput) CreateSessionInput { - var configuration struct { - Environment struct { - Type string `json:"type"` - } `json:"environment"` - } - if input.ModelProvider != nil || json.Unmarshal(input.Configuration, &configuration) != nil || !v1.ModelProviderRequired(configuration.Environment.Type) { - return input - } - input.ModelProvider, input.ModelProviderSource = FixtureModelProvider(input.Engine), v1.ModelProviderSourceSession - if input.ExecutionConfiguration != nil { - projection := *input.ExecutionConfiguration - projection.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} - input.ExecutionConfiguration = &projection - } - return input -} - -// FixtureCreator is an explicit synthetic principal for newly created test Sessions. -func FixtureCreator() identity.Subject { - return identity.Subject{Kind: "service_account", ID: "test-runner"} -} - -// FixtureExecutorPrincipal explicitly provisions a synthetic project for executor fixtures. -func FixtureExecutorPrincipal(t *testing.T, s *Store, tenant string) identity.Principal { - t.Helper() - p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: "test-org", ProjectID: tenant}, SubjectKind: FixtureCreator().Kind, SubjectID: FixtureCreator().ID} - if err := s.EnsureProjectScopes(t.Context(), []identity.ProjectScope{p.ProjectScope}); err != nil { - t.Fatal(err) - } - return p -} - -// SkillArchive builds a minimal valid Skill archive for public HTTP fixtures. -func SkillArchive(t *testing.T, marker string) []byte { return skillArchive(t, marker) } - -// CommitLegacyDeletion commits a deletion marker the way releases before the -// idle-only deletion rule did, for Sessions that public deletion now rejects. -func (s *Store) CommitLegacyDeletion(ctx context.Context, tenantID, sessionID string) error { - return s.commitLegacyDeletion(ctx, tenantID, sessionID) -} diff --git a/services/agents-api/internal/store/function_inputs.go b/services/agents-api/internal/store/function_inputs.go deleted file mode 100644 index 62737f068..000000000 --- a/services/agents-api/internal/store/function_inputs.go +++ /dev/null @@ -1,97 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// Result targets are resolved only inside a tenant-owned Session, after its -// lookup, so a missing or foreign Session still returns ErrNotFound (EVT-11). -var ( - // ErrUnknownFunctionCall rejects a result whose call_id names no function - // call in the Session. - ErrUnknownFunctionCall = errors.New("unknown pending tool call") - // ErrFunctionCallTurnMismatch rejects a result whose call exists in the - // Session but not in the named Turn, including a malformed or unknown Turn. - ErrFunctionCallTurnMismatch = errors.New("tool call belongs to a different turn") -) - -// FunctionResultInput identifies a persisted call; Result is validated by the API. -// It is an internal command, not an upstream input event. TurnID is the caller's -// value and is resolved within the Session at admission. -type FunctionResultInput struct { - TurnID string `json:"turn_id"` - CallID string `json:"call_id"` - Result json.RawMessage `json:"result"` -} - -func functionInput(raw json.RawMessage) (FunctionResultInput, error) { - var input FunctionResultInput - if json.Unmarshal(raw, &input) != nil || input.TurnID == "" || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { - return input, ErrInvalidInput - } - result, err := canonicalJSONObject(input.Result) - if err != nil { - return input, err - } - input.Result = result - return input, nil -} - -func admitFunctionResult(ctx context.Context, q *sqlc.Queries, tenantID string, session pgtype.UUID, key string, position int32, input Input) (InputReceipt, error) { - result, err := functionInput(input.Payload) - if err != nil { - return InputReceipt{}, err - } - tenant, err := parseID(tenantID) - if err != nil { - return InputReceipt{}, err - } - var turn sqlc.Turn - found := false - // A malformed Turn ID cannot name a Turn; it is classified like an unknown one. - if id, err := parseID(result.TurnID); err == nil { - turn, err = q.GetTurn(ctx, sqlc.GetTurnParams{TenantID: tenant, SessionID: session, ID: id}) - if err == nil { - found = true - } else if !errors.Is(err, pgx.ErrNoRows) { - return InputReceipt{}, err - } - } - if found { - err = storeFunctionResult(ctx, q, turn, result.CallID, result.Result) - if errors.Is(err, ErrNotFound) { - found = false - } else if err != nil { - return InputReceipt{}, err - } - } - if !found { - return InputReceipt{}, unknownFunctionResultTarget(ctx, q, session, result.CallID) - } - sequence, err := q.CreateTurnInput(ctx, sqlc.CreateTurnInputParams{ - SessionID: session, TurnID: turn.ID, IdempotencyKey: key, Kind: input.Kind, Payload: input.Payload, BatchPosition: position, - }) - if err != nil { - return InputReceipt{}, err - } - return inputReceipt(sequence, turn.ID, false), nil -} - -// unknownFunctionResultTarget classifies a result whose Turn does not own the -// call. The answer depends only on this Session's own calls. -func unknownFunctionResultTarget(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, callID string) error { - elsewhere, err := q.SessionHasFunctionCall(ctx, sqlc.SessionHasFunctionCallParams{SessionID: session, CallID: callID}) - if err != nil { - return err - } - if elsewhere { - return ErrFunctionCallTurnMismatch - } - return ErrUnknownFunctionCall -} diff --git a/services/agents-api/internal/store/initial_files.go b/services/agents-api/internal/store/initial_files.go deleted file mode 100644 index e4feab4b0..000000000 --- a/services/agents-api/internal/store/initial_files.go +++ /dev/null @@ -1,237 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "io" - "path" - "strings" - "unicode/utf8" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -const MaxInitialFileBytes = 50 << 20 - -// InitialFile keeps confidential input separate from ordinary Session configuration. -type InitialFile struct { - Type string `json:"type"` - Path string `json:"path"` - FileID string `json:"file_id,omitempty"` - Data []byte `json:"data,omitempty"` -} - -type InitialFileMetadata struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Path string `json:"path"` - FileID string `json:"file_id,omitempty"` - SizeBytes *int64 `json:"size_bytes,omitempty"` -} - -func ValidateInitialFiles(files []InitialFile) error { - if len(files) > 50 { - return ErrInvalidInput - } - total := 0 - seen := map[string]bool{} - for _, f := range files { - if !utf8.ValidString(f.Path) || strings.ContainsAny(f.Path, "\\\x00\r\n") || len(f.Path) > 4096 || !strings.HasPrefix(f.Path, "/workspace/") || path.Clean(f.Path) != f.Path || seen[f.Path] { - return ErrInvalidInput - } - seen[f.Path] = true - switch f.Type { - case "inline": - if f.FileID != "" || len(f.Data) > 5<<20 { - return ErrInvalidInput - } - total += len(f.Data) - case "file_id": - if f.FileID == "" || len(f.Data) != 0 { - return ErrInvalidInput - } - default: - return ErrInvalidInput - } - } - if total > 10<<20 { - return ErrInvalidInput - } - return nil -} - -func initialFileMetadata(files []InitialFile) []InitialFileMetadata { - result := make([]InitialFileMetadata, 0, len(files)) - for _, f := range files { - m := InitialFileMetadata{Type: f.Type, Path: f.Path, FileID: f.FileID} - if f.Type == "inline" { - size := int64(len(f.Data)) - m.SizeBytes = &size - } - result = append(result, m) - } - return result -} - -func (s *Store) sealTemplateFiles(tenant, id string, files []InitialFile) ([]byte, []byte, error) { - if err := ValidateInitialFiles(files); err != nil { - return nil, nil, err - } - metadata, err := json.Marshal(initialFileMetadata(files)) - if err != nil { - return nil, nil, err - } - if len(files) == 0 { - return metadata, nil, nil - } - input, err := json.Marshal(files) - if err != nil { - return nil, nil, err - } - encrypted, err := s.credentialCipher.SealEnvironmentFile(input, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "environment_template", OwnerID: id, FileID: "files"}) - return metadata, encrypted, err -} - -// ResolveEnvironmentTemplate reads one atomic snapshot; public reads need no decryption key. -func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { - return s.resolveEnvironmentTemplate(ctx, s.queries, tenant, id) -} - -func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { - lookup, err := deviceLookup(tenant, id) - if err != nil { - return EnvironmentTemplate{}, nil, ErrNotFound - } - row, err := q.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) - value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills, Plugins: row.Plugins, CapabilityDirectories: row.CapabilityDirectories}, err) - if err != nil { - return value, nil, err - } - value.Initialization.Packages = value.Packages - canonicalTenant := uuid.UUID(lookup.TenantID.Bytes).String() - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "env", row.EnvContents, &value.Initialization.Env); err != nil { - return value, nil, err - } - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { - return value, nil, err - } - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "skills", row.SkillContents, &value.Initialization.Skills); err != nil { - return value, nil, err - } - if len(value.Skills) != len(value.Initialization.Skills) { - return value, nil, ErrInvalidInput - } - for i, metadata := range value.Skills { - if metadata != value.Initialization.Skills[i].Metadata { - return value, nil, ErrInvalidInput - } - } - value.Initialization.CapabilityDirectories = append([]string(nil), value.CapabilityDirectories...) - if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "plugins", row.PluginContents, &value.Initialization.Plugins); err != nil { - return value, nil, err - } - if len(value.Plugins) != len(value.Initialization.Plugins) { - return value, nil, ErrInvalidInput - } - for i, metadata := range value.Plugins { - if metadata != value.Initialization.Plugins[i].Metadata { - return value, nil, ErrInvalidInput - } - } - if err = value.Initialization.Validate(); err != nil { - return value, nil, err - } - if len(row.FileContents) == 0 { - if len(value.Files) > 0 { - return value, nil, ErrInvalidInput - } - return value, nil, nil - } - plain, err := s.credentialCipher.OpenEnvironmentFile(row.FileContents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "environment_template", OwnerID: value.ID, FileID: "files"}) - if err != nil { - return value, nil, err - } - var files []InitialFile - if json.Unmarshal(plain, &files) != nil || ValidateInitialFiles(files) != nil { - return value, nil, ErrInvalidInput - } - return value, files, nil -} - -func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx, tenant string, session pgtype.UUID, files []InitialFile) ([]byte, error) { - if err := ValidateInitialFiles(files); err != nil { - return nil, err - } - tenantID, err := parseID(tenant) - if err != nil { - return nil, err - } - tenant = uuid.UUID(tenantID.Bytes).String() - metadata := initialFileMetadata(files) - for i, f := range files { - body := f.Data - if f.Type == "file_id" { - sourceTenant, sourceID, err := sourceFileIDs(tenant, f.FileID) - if err != nil { - return nil, err - } - source, err := q.LockInitialSourceFile(ctx, sqlc.LockInitialSourceFileParams{TenantID: sourceTenant, ID: sourceID}) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - if err != nil { - return nil, err - } - err = consumeSourceFile(ctx, tx, source, func(source SourceFile, reader io.Reader) error { - if source.SizeBytes > MaxInitialFileBytes { - return ErrInvalidInput - } - var err error - body, err = io.ReadAll(io.LimitReader(reader, MaxInitialFileBytes+1)) - if err == nil && (len(body) > MaxInitialFileBytes || int64(len(body)) != source.SizeBytes) { - return ErrInvalidInput - } - return err - }) - if err != nil { - return nil, err - } - } - id := uuid.NewString() - size := int64(len(body)) - metadata[i].ID = id - metadata[i].SizeBytes = &size - encrypted, err := s.credentialCipher.SealEnvironmentFile(body, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "session", OwnerID: uuid.UUID(session.Bytes).String(), FileID: id}) - if err != nil { - return nil, err - } - fileID, _ := parseID(id) - if err := q.CreateInitialEnvironmentFile(ctx, sqlc.CreateInitialEnvironmentFileParams{ID: fileID, SessionID: session, Position: int32(i), Path: f.Path, SizeBytes: size, Contents: encrypted}); err != nil { - return nil, err - } - } - return json.Marshal(metadata) -} - -// ReadInitialEnvironmentFile decrypts only the next frozen file, bounding memory per installation. -func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (InitialFileMetadata, []byte, error) { - lookup, err := deviceLookup(tenant, session) - if err != nil { - return InitialFileMetadata{}, nil, err - } - row, err := s.queries.GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, SessionID: lookup.ID, Position: int32(position)}) - if err != nil { - return InitialFileMetadata{}, nil, err - } - id := uuid.UUID(row.ID.Bytes).String() - body, err := s.credentialCipher.OpenEnvironmentFile(row.Contents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "session", OwnerID: uuid.UUID(lookup.ID.Bytes).String(), FileID: id}) - if err == nil && int64(len(body)) != row.SizeBytes { - err = ErrInvalidInput - } - return InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, err -} diff --git a/services/agents-api/internal/store/mcp_credentials.go b/services/agents-api/internal/store/mcp_credentials.go deleted file mode 100644 index 4dbe48b54..000000000 --- a/services/agents-api/internal/store/mcp_credentials.go +++ /dev/null @@ -1,196 +0,0 @@ -package store - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type MCPCredentialRequest struct { - ServerLabel, ServerURL string - CredentialID *string -} - -// MCPCredentialBinding freezes a non-secret selection, including anonymous -// servers. It is private execution configuration, not the public MCP tool shape. -type MCPCredentialBinding struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url"` - VaultID string `json:"vault_id,omitempty"` - CredentialID string `json:"credential_id,omitempty"` - AuthType string `json:"auth_type,omitempty"` -} - -// MCPCredentialSelectionError rejects a Session MCP credential selection with -// the observed official message (MV-03); the API reports a Conflict as 409 -// conflict_error and any other as 400 invalid_request_error. Selection searches -// only the attached Vaults, which the caller owns, so a missing, foreign-tenant, -// unattached or malformed reference produces the same error, and only a -// credential of an attached Vault can report a server_url mismatch. -type MCPCredentialSelectionError struct { - Conflict bool - Message string -} - -func (e *MCPCredentialSelectionError) Error() string { return e.Message } - -// echoed repeats a caller-supplied value in a selection message only within -// the shared bound; otherwise the message leaves it out. -func echoed(value string) string { - if !echotext.Allowed(value) { - return "" - } - return " " + value -} - -func mcpCredentialRequiresVault() error { - return &MCPCredentialSelectionError{Message: "MCP credential_id requires an attached vault"} -} - -func mcpCredentialNotAttached(id string) error { - return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " was not found in an attached vault"} -} - -func mcpCredentialURLMismatch(id, url string) error { - return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " does not match server_url" + echoed(url)} -} - -func mcpCredentialAmbiguous(url string) error { - return &MCPCredentialSelectionError{Conflict: true, Message: "multiple attached vault credentials match MCP server_url" + echoed(url) + "; specify credential_id"} -} - -func attachedVaultIDs(ids []string) ([]pgtype.UUID, error) { - result := make([]pgtype.UUID, 0, len(ids)) - seen := map[pgtype.UUID]bool{} - for _, raw := range ids { - id, err := parseID(raw) - if err != nil { - return nil, ErrNotFound - } - if !seen[id] { - result = append(result, id) - seen[id] = true - } - } - return result, nil -} - -// ResolveMCPCredentials reads metadata only. Resource changes after this read do -// not reselect credentials for an accepted Session or its creation retries. -func (s *Store) ResolveMCPCredentials(ctx context.Context, tenantID string, vaultIDs []string, requests []MCPCredentialRequest) ([]MCPCredentialBinding, error) { - tenant, err := parseID(tenantID) - if err != nil { - return nil, err - } - vaults, err := attachedVaultIDs(vaultIDs) - if err != nil { - return nil, err - } - owned, err := s.queries.GetAttachedVaultIDs(ctx, sqlc.GetAttachedVaultIDsParams{TenantID: tenant, VaultIds: vaults}) - if err != nil { - return nil, errors.New("cannot resolve attached Vaults") - } - if len(owned) != len(vaults) { - return nil, ErrNotFound - } - bindings := make([]MCPCredentialBinding, 0, len(requests)) - for _, request := range requests { - if request.ServerLabel == "" || request.ServerURL == "" { - return nil, ErrInvalidInput - } - var id pgtype.UUID - if request.CredentialID != nil { - if len(vaults) == 0 { - return nil, mcpCredentialRequiresVault() - } - id, err = parseID(*request.CredentialID) - if err != nil { - return nil, mcpCredentialNotAttached(*request.CredentialID) - } - } - rows, err := s.queries.FindMCPCredentials(ctx, sqlc.FindMCPCredentialsParams{ - TenantID: tenant, VaultIds: vaults, McpServerUrl: request.ServerURL, CredentialID: id, - }) - if err != nil { - return nil, errors.New("cannot resolve MCP credential") - } - if request.CredentialID != nil { - if len(rows) == 0 { - return nil, mcpCredentialNotAttached(*request.CredentialID) - } - if rows[0].McpServerUrl != request.ServerURL { - return nil, mcpCredentialURLMismatch(*request.CredentialID, request.ServerURL) - } - } - if len(rows) > 1 { - return nil, mcpCredentialAmbiguous(request.ServerURL) - } - binding := MCPCredentialBinding{ServerLabel: request.ServerLabel, ServerURL: request.ServerURL} - if len(rows) == 1 { - binding.VaultID, binding.CredentialID = uuid.UUID(rows[0].VaultID.Bytes).String(), uuid.UUID(rows[0].ID.Bytes).String() - binding.AuthType = rows[0].AuthType - } - bindings = append(bindings, binding) - } - return bindings, nil -} - -// MCPBearerToken is execution-only: recheck the complete frozen authorization -// before decrypting. Never persist or log its result, or downgrade failure to an -// anonymous request. Public resource queries do not select ciphertext. -func (s *Store) MCPBearerToken(ctx context.Context, tenantID string, vaultIDs []string, binding MCPCredentialBinding) (string, error) { - tenant, err := parseID(tenantID) - if err != nil { - return "", ErrNotFound - } - vaults, err := attachedVaultIDs(vaultIDs) - if err != nil { - return "", err - } - vault, err := parseID(binding.VaultID) - if err != nil { - return "", ErrNotFound - } - id, err := parseID(binding.CredentialID) - if err != nil || (binding.AuthType != "static_bearer" && binding.AuthType != "mcp_oauth") || binding.ServerURL == "" { - return "", ErrNotFound - } - if binding.AuthType == "mcp_oauth" { - attached := false - for _, candidate := range vaults { - if candidate == vault { - attached = true - } - } - if !attached { - return "", ErrNotFound - } - return s.oauthBearerToken(ctx, tenantID, binding) - } - ciphertext, err := s.queries.GetMCPStaticCredentialCiphertext(ctx, sqlc.GetMCPStaticCredentialCiphertextParams{ - TenantID: tenant, VaultIds: vaults, VaultID: vault, CredentialID: id, McpServerUrl: binding.ServerURL, - }) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", errors.New("cannot read MCP credential") - } - if s.credentialCipher == nil { - return "", ErrCredentialStorageUnavailable - } - plaintext, err := s.credentialCipher.Open(ciphertext, credentialcrypto.Binding{ - TenantID: uuid.UUID(tenant.Bytes).String(), VaultID: uuid.UUID(vault.Bytes).String(), CredentialID: uuid.UUID(id.Bytes).String(), - AuthType: binding.AuthType, Destination: binding.ServerURL, - }) - if err != nil { - return "", errors.New("MCP credential decryption failed") - } - return string(plaintext), nil -} diff --git a/services/agents-api/internal/store/mcp_credentials_test.go b/services/agents-api/internal/store/mcp_credentials_test.go deleted file mode 100644 index bf4862f1a..000000000 --- a/services/agents-api/internal/store/mcp_credentials_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package store - -import ( - "bytes" - "crypto/rand" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { - public, pool := testStore(t) - tenant, foreign := uuid.NewString(), uuid.NewString() - key := make([]byte, 32) - if _, err := rand.Read(key); err != nil { - t.Fatal(err) - } - cipher, err := credentialcrypto.New(key) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - var vaults []Vault - for _, owner := range []string{tenant, tenant, foreign} { - vault, err := s.CreateVault(t.Context(), owner, CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - vaults = append(vaults, vault) - } - token := " \t" + uuid.NewString() + "雪\n" - destination := "https://mcp.example/tools" - create := func(vault Vault) Credential { - t.Helper() - value, err := s.CreateStaticCredential(t.Context(), vault.TenantID, vault.ID, CreateStaticCredentialInput{Name: "private", MCPServerURL: destination, Token: token}) - if err != nil { - t.Fatal(err) - } - return value - } - first, foreignCredential := create(vaults[0]), create(vaults[2]) - attached := []string{vaults[0].ID, vaults[1].ID} - requests := []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination}, {ServerLabel: "anonymous", ServerURL: "https://anonymous.example/mcp"}} - bindings, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) - if err != nil || len(bindings) != 2 || bindings[0].CredentialID != first.ID || bindings[0].AuthType != "static_bearer" || bindings[1].CredentialID != "" { - t.Fatal("metadata selection or frozen anonymous decision differs", err) - } - encoded, err := json.Marshal(bindings) - if err != nil || bytes.Contains(encoded, []byte(token)) || strings.Contains(string(encoded), "ciphertext") { - t.Fatal("private binding contains secret material") - } - second := create(vaults[1]) - if _, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+destination+"; specify credential_id") { - t.Fatal("ambiguous selection was admitted", err) - } - requests[0].CredentialID = &second.ID - explicit, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) - if err != nil || explicit[0].CredentialID != second.ID || requests[1].CredentialID != nil { - t.Fatal("explicit selection did not disambiguate", err) - } - notAttached := func(id string) string { return "MCP credential_id " + id + " was not found in an attached vault" } - for _, tc := range []struct { - owner string - vaults []string - id, url string - message string // Empty for the unchanged Vault 404. - }{ - {tenant, attached, foreignCredential.ID, destination, notAttached(foreignCredential.ID)}, - {tenant, []string{vaults[1].ID}, first.ID, destination, notAttached(first.ID)}, - {tenant, attached, "not-a-credential", destination, notAttached("not-a-credential")}, - {tenant, attached, first.ID, destination + "/other", "MCP credential_id " + first.ID + " does not match server_url " + destination + "/other"}, - {foreign, attached, first.ID, destination, ""}, - {tenant, []string{vaults[0].ID, vaults[2].ID}, first.ID, destination, ""}, - {tenant, []string{uuid.NewString()}, first.ID, destination, ""}, - } { - _, err := public.ResolveMCPCredentials(t.Context(), tc.owner, tc.vaults, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) - if tc.message == "" && !errors.Is(err, ErrNotFound) || tc.message != "" && !isSelectionError(err, false, tc.message) { - t.Fatal("unowned, unattached or wrong-destination selection was admitted", err) - } - } - if _, err := public.ResolveMCPCredentials(t.Context(), tenant, nil, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination, CredentialID: &first.ID}}); !isSelectionError(err, false, "MCP credential_id requires an attached vault") { - t.Fatal("a reference without attachments was admitted", err) - } - pool.Close() - public, pool = testStore(t) - cipher, _ = credentialcrypto.New(bytes.Clone(key)) - s = NewWithCredentialCipher(pool, cipher) - got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]) - if err != nil || got != token { - t.Fatal("frozen selection or opaque bytes changed across restart", err) - } - if got, err := public.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); !errors.Is(err, ErrCredentialStorageUnavailable) || got != "" { - t.Fatal("missing key did not fail execution closed") - } - key[0] ^= 1 - wrong, _ := credentialcrypto.New(key) - if got, err := NewWithCredentialCipher(pool, wrong).MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" || strings.Contains(err.Error(), token) { - t.Fatal("wrong key leaked or decrypted a credential") - } - for _, mutate := range []func(*MCPCredentialBinding){ - func(b *MCPCredentialBinding) { b.VaultID = vaults[1].ID }, - func(b *MCPCredentialBinding) { b.CredentialID = foreignCredential.ID }, - func(b *MCPCredentialBinding) { b.ServerURL += "/other" }, - func(b *MCPCredentialBinding) { b.AuthType = "other" }, - } { - binding := bindings[0] - mutate(&binding) - if got, err := s.MCPBearerToken(t.Context(), tenant, attached, binding); !errors.Is(err, ErrNotFound) || got != "" { - t.Fatal("substituted frozen authorization was decrypted") - } - } - for _, scope := range []struct { - owner string - vaults []string - }{{foreign, attached}, {tenant, []string{vaults[1].ID}}} { - if got, err := s.MCPBearerToken(t.Context(), scope.owner, scope.vaults, bindings[0]); !errors.Is(err, ErrNotFound) || got != "" { - t.Fatal("tenant or attachment authorization was bypassed") - } - } - if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext, 15, get_byte(token_ciphertext,15) # 1) WHERE id=$1", first.ID); err != nil { - t.Fatal(err) - } - if got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" { - t.Fatal("tampered ciphertext decrypted") - } - if _, err := public.GetCredential(t.Context(), tenant, first.VaultID, first.ID); err != nil { - t.Fatal("safe metadata lookup depended on ciphertext", err) - } -} - -func isSelectionError(err error, conflict bool, message string) bool { - var selection *MCPCredentialSelectionError - return errors.As(err, &selection) && selection.Conflict == conflict && selection.Message == message -} diff --git a/services/agents-api/internal/store/model_provider_fixture_test.go b/services/agents-api/internal/store/model_provider_fixture_test.go deleted file mode 100644 index 45681d9c4..000000000 --- a/services/agents-api/internal/store/model_provider_fixture_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "github.com/google/uuid" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// Hosted and self-hosted Sessions must freeze a model provider. HTTP fixtures -// supply one here instead of relaxing that check; the store needs a credential -// key (store.NewModelTestStore). - -// fixtureDeploymentProvider configures a deployment default for every harness. -func fixtureDeploymentProvider() api.Option { - return api.WithModelProviderDefaults(func(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: store.FixtureModelProvider(harness), Revision: uuid.New()}, nil - }) -} - -// fixtureSessionProvider is the top-level Session request member that supplies -// the harness's fixture bundle, for self-hosted Sessions. -func fixtureSessionProvider(harness string) string { - raw, _ := json.Marshal(map[string]any{"model_provider": store.FixtureModelProvider(harness)}) - return `"x_agents_core":` + string(raw) -} diff --git a/services/agents-api/internal/store/project_api_keys.go b/services/agents-api/internal/store/project_api_keys.go deleted file mode 100644 index 74a4a5ba2..000000000 --- a/services/agents-api/internal/store/project_api_keys.go +++ /dev/null @@ -1,197 +0,0 @@ -package store - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -var ErrProjectAPIKeyExists = errors.New("project API key ID already exists") - -type ProjectAPIKey struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - Prefix string `json:"prefix"` - CreatedAt time.Time `json:"created_at"` - RevokedAt *time.Time `json:"revoked_at"` -} -type IssuedProjectAPIKey struct { - ProjectAPIKey - Key string `json:"key"` -} -type ProjectAPIKeyBinding struct { - Key ProjectAPIKey - Principal identity.Principal -} -type ProjectAPIKeyPage struct { - Data []ProjectAPIKey `json:"data"` - HasMore bool `json:"has_more"` -} - -func validProjectKeyDigest(digest string) bool { - decoded, err := hex.DecodeString(digest) - return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == digest -} -func projectKeyMetadata(row sqlc.ProjectApiKey) ProjectAPIKey { - key := ProjectAPIKey{ID: uuid.UUID(row.ID.Bytes).String(), ProjectID: uuid.UUID(row.ProjectID.Bytes).String(), Name: row.Name, Prefix: row.Prefix, CreatedAt: row.CreatedAt.Time} - if row.RevokedAt.Valid { - v := row.RevokedAt.Time - key.RevokedAt = &v - } - return key -} -func newProjectSecret() (string, string, error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return "", "", err - } - token := "pc_" + base64.RawURLEncoding.EncodeToString(raw) - digest := sha256.Sum256([]byte(token)) - return token, hex.EncodeToString(digest[:]), nil -} -func (s *Store) CreateProjectAPIKey(ctx context.Context, project, id, name string) (IssuedProjectAPIKey, error) { - projectID, err := parseID(project) - if err != nil { - return IssuedProjectAPIKey{}, ErrNotFound - } - keyID, err := parseID(id) - if err != nil { - return IssuedProjectAPIKey{}, err - } - name, err = adminResourceName(name, 80) - if err != nil { - return IssuedProjectAPIKey{}, err - } - token, digest, err := newProjectSecret() - if err != nil { - return IssuedProjectAPIKey{}, err - } - var result IssuedProjectAPIKey - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - p, err := q.LockProject(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if p.ArchivedAt.Valid { - return ErrProjectArchived - } - row, err := q.CreateProjectAPIKey(ctx, sqlc.CreateProjectAPIKeyParams{ID: keyID, Name: name, Prefix: token[:11], TokenSha256: digest, ProjectID: projectID}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrProjectAPIKeyExists - } - if err != nil { - return err - } - result = IssuedProjectAPIKey{ProjectAPIKey: projectKeyMetadata(row), Key: token} - return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "create", "api_key", id) - }) - if err != nil { - return IssuedProjectAPIKey{}, err - } - return result, nil -} -func (s *Store) ListProjectAPIKeys(ctx context.Context, project, after string, limit int, ascending bool) (ProjectAPIKeyPage, error) { - result := ProjectAPIKeyPage{Data: []ProjectAPIKey{}} - if limit < 1 || limit > 100 { - return result, ErrInvalidInput - } - p, err := s.GetProject(ctx, project) - if err != nil { - return result, err - } - projectID, _ := parseID(p.Project.ID) - if after != "" { - id, err := parseID(after) - if err != nil { - return result, ErrNotFound - } - if _, err := s.queries.GetProjectAPIKeyForProject(ctx, sqlc.GetProjectAPIKeyForProjectParams{ID: id, ProjectID: projectID}); errors.Is(err, pgx.ErrNoRows) { - return result, ErrNotFound - } else if err != nil { - return result, err - } - } - rows, err := s.queries.ListProjectAPIKeys(ctx, sqlc.ListProjectAPIKeysParams{ProjectID: projectID, AfterID: after, Ascending: ascending, PageLimit: int32(limit + 1)}) - if err != nil { - return result, err - } - result.HasMore = len(rows) > limit - if result.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - result.Data = append(result.Data, projectKeyMetadata(row)) - } - return result, nil -} -func (s *Store) RevokeProjectAPIKey(ctx context.Context, project, id string) error { - projectID, err := parseID(project) - if err != nil { - return ErrNotFound - } - keyID, err := parseID(id) - if err != nil { - return ErrNotFound - } - return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - p, err := q.LockProject(ctx, projectID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - _, err = q.RevokeProjectAPIKey(ctx, sqlc.RevokeProjectAPIKeyParams{ID: keyID, ProjectID: projectID}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "revoke", "api_key", id) - }) -} -func (s *Store) ResolveProjectAPIKey(ctx context.Context, digest string) (ProjectAPIKeyBinding, error) { - if !validProjectKeyDigest(digest) { - return ProjectAPIKeyBinding{}, ErrNotFound - } - row, err := s.queries.ResolveProjectAPIKey(ctx, digest) - if errors.Is(err, pgx.ErrNoRows) { - return ProjectAPIKeyBinding{}, ErrNotFound - } - if err != nil { - return ProjectAPIKeyBinding{}, err - } - key := projectKeyMetadata(sqlc.ProjectApiKey{ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Prefix: row.Prefix, TokenSha256: row.TokenSha256, CreatedAt: row.CreatedAt, RevokedAt: row.RevokedAt}) - return ProjectAPIKeyBinding{Key: key, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.UUID(row.TenantID.Bytes).String(), OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}, nil -} - -// ValidateProjectKeySeparation checks configured credentials against all stored -// digests, including revoked credentials, before the server starts. -func (s *Store) ValidateProjectKeySeparation(ctx context.Context, digests []string) error { - for _, digest := range digests { - exists, err := s.queries.ProjectAPIKeyDigestExists(ctx, digest) - if err != nil { - return err - } - if exists { - return errors.New("the Core key overlaps a persisted project API key") - } - } - return nil -} diff --git a/services/agents-api/internal/store/project_api_keys_test.go b/services/agents-api/internal/store/project_api_keys_test.go deleted file mode 100644 index c40ce8c0b..000000000 --- a/services/agents-api/internal/store/project_api_keys_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package store - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/google/uuid" -) - -func projectKeyDigest(value string) string { - digest := sha256.Sum256([]byte(value)) - return hex.EncodeToString(digest[:]) -} -func projectKeyPrincipal() identity.Principal { - return identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.NewString(), OrganizationID: "org-" + uuid.NewString(), ProjectID: "project-" + uuid.NewString()}, SubjectKind: "service_account", SubjectID: "test"} -} -func keyAdminContext(ctx context.Context, id string) context.Context { - return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: id}) -} -func createTestProject(t *testing.T, s *Store) Project { - t.Helper() - id := uuid.NewString() - p, err := s.CreateProject(keyAdminContext(t.Context(), id), id, "Project") - if err != nil { - t.Fatal(err) - } - return p -} -func TestProjectKeysShareIdentityAndArchiveRetainsAssets(t *testing.T) { - s, _ := testStore(t) - p := createTestProject(t, s) - other := createTestProject(t, s) - first, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "first") - if err != nil { - t.Fatal(err) - } - second, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "second") - if err != nil { - t.Fatal(err) - } - a, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) - if err != nil { - t.Fatal(err) - } - b, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)) - if err != nil || a.Principal != b.Principal || a.Principal.SubjectID != "project:"+p.ID || a.Principal.ProjectID != "proj_"+p.ID { - t.Fatal("Project keys do not share the stable Project principal", err) - } - asset, err := s.CreateAgent(t.Context(), a.Principal.TenantID, CreateAgentInput{Configuration: []byte(`{"model":"test"}`)}) - if err != nil { - t.Fatal(err) - } - if _, err := s.GetAgent(t.Context(), b.Principal.TenantID, asset.ID); err != nil { - t.Fatal("peer key cannot read shared asset", err) - } - if _, err := s.GetAgent(t.Context(), other.TenantID, asset.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign Project read asset", err) - } - renamed, err := s.RenameProject(keyAdminContext(t.Context(), p.ID), p.ID, "renamed") - if err != nil || renamed.TenantID != p.TenantID || renamed.ActiveKeyCount != 2 { - t.Fatal("rename changed Project ownership", err) - } - afterRename, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) - if err != nil || afterRename.Principal != a.Principal { - t.Fatal("rename changed key principal", err) - } - listed, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 50, true) - if err != nil || len(listed.Data) != 2 { - t.Fatal("Project keys missing", err) - } - raw, _ := json.Marshal(listed) - if strings.Contains(string(raw), first.Key) || strings.Contains(string(raw), projectKeyDigest(first.Key)) { - t.Fatal("key list exposed credential") - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), other.ID), other.ID, first.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign Project revoked key", err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, first.ID); err != nil { - t.Fatal(err) - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)); !errors.Is(err, ErrNotFound) { - t.Fatal("revoked key authenticated") - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); err != nil { - t.Fatal("revocation affected peer", err) - } - archived, err := s.ArchiveProject(keyAdminContext(t.Context(), p.ID), p.ID) - if err != nil || archived.ArchivedAt == nil || archived.ActiveKeyCount != 0 { - t.Fatal("archive did not revoke all keys", err) - } - if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); !errors.Is(err, ErrNotFound) { - t.Fatal("archived Project authenticated") - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "late"); !errors.Is(err, ErrProjectArchived) { - t.Fatal("archived Project admitted new key", err) - } - if _, err := s.GetAgent(t.Context(), p.TenantID, asset.ID); err != nil { - t.Fatal("archive removed assets", err) - } - if err := s.ValidateProjectKeySeparation(t.Context(), []string{projectKeyDigest(second.Key)}); err == nil { - t.Fatal("revoked credential collision accepted") - } - raw, _ = json.Marshal(archived) - if strings.Contains(string(raw), p.TenantID) { - t.Fatal("Project response exposed internal tenant") - } -} -func TestProjectManagementRequiresAtomicAudit(t *testing.T) { - s, _ := testStore(t) - id := uuid.NewString() - if _, err := s.CreateProject(t.Context(), id, "unaudited"); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unaudited Project accepted") - } - if _, err := s.GetProject(t.Context(), id); !errors.Is(err, ErrNotFound) { - t.Fatal("unaudited Project persisted") - } - p := createTestProject(t, s) - keyID := uuid.NewString() - if _, err := s.CreateProjectAPIKey(t.Context(), p.ID, keyID, "unaudited"); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unaudited key accepted") - } - page, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 20, true) - if err != nil || len(page.Data) != 0 { - t.Fatal("unaudited key persisted") - } - if _, err := s.RenameProject(t.Context(), p.ID, "bad"); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unaudited rename accepted") - } - if _, err := s.ArchiveProject(t.Context(), p.ID); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unaudited archive accepted") - } - current, err := s.GetProject(t.Context(), p.ID) - if err != nil || current.Project.Name != p.Name || current.Project.ArchivedAt != nil { - t.Fatal("unaudited mutation persisted") - } -} -func TestProjectNamesValidateBeforeDatabaseAccess(t *testing.T) { - s := &Store{} - for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 129)} { - if _, err := s.CreateProject(t.Context(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { - t.Fatal("invalid Project name accepted") - } - } - for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 81)} { - if _, err := s.CreateProjectAPIKey(t.Context(), uuid.NewString(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { - t.Fatal("invalid key name accepted") - } - } -} - -func TestProjectCatalogPaginationAndScopedKeyCursor(t *testing.T) { - s, _ := testStore(t) - first, second := createTestProject(t, s), createTestProject(t, s) - if _, err := s.CreateProject(keyAdminContext(t.Context(), first.ID), first.ID, "duplicate"); !errors.Is(err, ErrProjectExists) { - t.Fatal("duplicate Project ID did not conflict", err) - } - // Equal display names do not merge Projects or keys. - a, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") - if err != nil { - t.Fatal(err) - } - b, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") - if err != nil { - t.Fatal(err) - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID, "duplicate ID"); !errors.Is(err, ErrProjectAPIKeyExists) { - t.Fatal("duplicate key ID did not conflict", err) - } - page, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, true) - if err != nil || len(page.Data) != 1 || !page.HasMore { - t.Fatal("first key page invalid", err) - } - tail, err := s.ListProjectAPIKeys(t.Context(), first.ID, page.Data[0].ID, 1, true) - if err != nil || len(tail.Data) != 1 || tail.HasMore || tail.Data[0].ID <= page.Data[0].ID { - t.Fatal("key cursor did not advance", err) - } - reverse, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, false) - if err != nil || len(reverse.Data) != 1 || reverse.Data[0].ID != tail.Data[0].ID { - t.Fatal("descending key page invalid", err) - } - if _, err := s.ListProjectAPIKeys(t.Context(), second.ID, a.ID, 1, true); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign key cursor accepted", err) - } - if _, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 101, true); !errors.Is(err, ErrInvalidInput) { - t.Fatal("oversized key page accepted", err) - } - if _, err := s.ListProjects(t.Context(), "", 101, true); !errors.Is(err, ErrInvalidInput) { - t.Fatal("oversized Project page accepted", err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, b.ID); err != nil { - t.Fatal(err) - } - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID); err != nil { - t.Fatal(err) - } - current, err := s.GetProject(t.Context(), first.ID) - if err != nil || current.Project.ArchivedAt != nil || current.Project.ActiveKeyCount != 0 { - t.Fatal("last key removal changed Project lifecycle", err) - } - if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "new access"); err != nil { - t.Fatal("zero-key Project could not issue another key", err) - } -} diff --git a/services/agents-api/internal/store/provider_operations_fixture_test.go b/services/agents-api/internal/store/provider_operations_fixture_test.go deleted file mode 100644 index b285590b3..000000000 --- a/services/agents-api/internal/store/provider_operations_fixture_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package store_test - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -func (*lifecycleProvider) ProviderOperations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - } -} -func (*lifecycleProvider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { - return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { - return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { - return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} -} -func (*lifecycleProvider) VerifyCredential(context.Context, []sandbox.Reference) error { - return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} -} -func (*fakeCheckpointProvider) ProviderOperations() providercontract.Operations { - return providercontract.Operations{ - "Create": {State: providercontract.Supported}, - "GetInfo": {State: providercontract.Supported}, - "Renew": {State: providercontract.Supported}, - "Kill": {State: providercontract.Supported}, - "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Supported}, - "NewCompute": {State: providercontract.Supported}, - "GetCompute": {State: providercontract.Supported}, - "Suspend": {State: providercontract.Supported}, - "Resume": {State: providercontract.Supported}, - "KillCompute": {State: providercontract.Supported}, - "DeleteSnapshot": {State: providercontract.Supported}, - "RunCommandCompute": {State: providercontract.Supported}, - "ResumeCompute": {State: providercontract.Supported}, - "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, - } -} diff --git a/services/agents-api/internal/store/runtime_history.go b/services/agents-api/internal/store/runtime_history.go deleted file mode 100644 index fa7f98518..000000000 --- a/services/agents-api/internal/store/runtime_history.go +++ /dev/null @@ -1,139 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -// InsertRuntimeHistorySample copies a sanitized periodic observation. Public -// Session/Turn Usage remains the accounting authority; these measured counters -// are only sampled chart values. Deleted or mismatched owners cannot create orphan rows. -func (s *Store) InsertRuntimeHistorySample(ctx context.Context, record runtimeobs.ExportRecord) error { - tenant, err := parseID(record.TenantID) - if err != nil { - return err - } - session, err := parseID(record.SessionID) - if err != nil { - return err - } - environment, err := parseID(record.EnvironmentID) - if err != nil { - return err - } - params := sqlc.InsertRuntimeHistorySampleParams{ - TenantID: tenant, SessionID: session, EnvironmentID: environment, - ResolvedAtNs: record.ResolvedAt.UnixNano(), ProviderType: record.ProviderType, Status: string(record.Status), - } - if record.AllocationID != "" { - params.AllocationID, err = parseID(record.AllocationID) - if err != nil { - return err - } - } - if sample := record.Sample; sample != nil { - params.ObservedAtNs = pgtype.Int8{Int64: sample.ObservedAt.UnixNano(), Valid: true} - if sample.StartedAt != nil { - params.StartedAtNs = pgtype.Int8{Int64: sample.StartedAt.UnixNano(), Valid: true} - } - params.CpuUsageSeconds = historyFloat(sample.CPUUsageSecondsTotal) - params.CpuCapacityCores = historyFloat(sample.CPUCapacityCores) - params.CpuUtilizationRatio = historyFloat(sample.CPUUtilizationRatio) - params.MemoryUsageBytes = historyInteger(sample.MemoryUsageBytes) - params.MemoryLimitBytes = historyInteger(sample.MemoryLimitBytes) - } - if usage := record.TokenUsage; usage != nil { - params.InputTokens = historyInteger(&usage.InputTokens) - params.OutputTokens = historyInteger(&usage.OutputTokens) - } - return s.queries.InsertRuntimeHistorySample(ctx, params) -} - -func (s *Store) ListRuntimeHistorySamples(ctx context.Context, tenantID, sessionID, environmentID string, startNS, endNS int64, limit int32) ([]runtimeobs.ExportRecord, error) { - tenant, err := parseID(tenantID) - if err != nil { - return nil, err - } - session, err := parseID(sessionID) - if err != nil { - return nil, err - } - environment, err := parseID(environmentID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListRuntimeHistorySamples(ctx, sqlc.ListRuntimeHistorySamplesParams{ - TenantID: tenant, SessionID: session, EnvironmentID: environment, StartNs: startNS, EndNs: endNS, RowLimit: limit, - }) - if err != nil { - return nil, err - } - records := make([]runtimeobs.ExportRecord, 0, len(rows)) - for _, row := range rows { - record := runtimeobs.ExportRecord{ - TenantID: tenantID, SessionID: sessionID, EnvironmentID: environmentID, - Mode: runtimeobs.ModeManaged, CollectionSource: runtimeobs.CollectionSourcePeriodic, - ResolvedAt: time.Unix(0, row.ResolvedAtNs).UTC(), ProviderType: row.ProviderType, Status: runtimeobs.Status(row.Status), - } - if row.AllocationID.Valid { - record.AllocationID = uuid.UUID(row.AllocationID.Bytes).String() - } - if row.ObservedAtNs.Valid { - record.Sample = &runtimeobs.Sample{ - ObservedAt: time.Unix(0, row.ObservedAtNs.Int64).UTC(), - CPUUsageSecondsTotal: historyFloatPointer(row.CpuUsageSeconds), CPUCapacityCores: historyFloatPointer(row.CpuCapacityCores), - CPUUtilizationRatio: historyFloatPointer(row.CpuUtilizationRatio), - MemoryUsageBytes: historyIntegerPointer(row.MemoryUsageBytes), MemoryLimitBytes: historyIntegerPointer(row.MemoryLimitBytes), - } - if row.StartedAtNs.Valid { - value := time.Unix(0, row.StartedAtNs.Int64).UTC() - record.Sample.StartedAt = &value - } - } - if row.InputTokens.Valid && row.OutputTokens.Valid { - record.TokenUsage = &runtimeobs.TokenUsage{InputTokens: uint64(row.InputTokens.Int64), OutputTokens: uint64(row.OutputTokens.Int64)} - } - records = append(records, record) - } - return records, nil -} - -func (s *Store) PruneRuntimeHistorySamples(ctx context.Context, beforeNS int64) (int64, error) { - count, err := s.queries.PruneRuntimeHistorySamples(ctx, beforeNS) - if err != nil { - return count, err - } - nodes, err := s.queries.PruneNodeHostHistory(ctx, pgtype.Timestamptz{Time: time.Unix(0, beforeNS), Valid: true}) - return count + nodes, err -} - -func historyFloat(value *float64) pgtype.Float8 { - if value == nil { - return pgtype.Float8{} - } - return pgtype.Float8{Float64: *value, Valid: true} -} -func historyInteger(value *uint64) pgtype.Int8 { - if value == nil { - return pgtype.Int8{} - } - return pgtype.Int8{Int64: int64(*value), Valid: true} -} -func historyFloatPointer(value pgtype.Float8) *float64 { - if !value.Valid { - return nil - } - return &value.Float64 -} -func historyIntegerPointer(value pgtype.Int8) *uint64 { - if !value.Valid { - return nil - } - result := uint64(value.Int64) - return &result -} diff --git a/services/agents-api/internal/store/runtime_initialization.go b/services/agents-api/internal/store/runtime_initialization.go deleted file mode 100644 index 30d6a7870..000000000 --- a/services/agents-api/internal/store/runtime_initialization.go +++ /dev/null @@ -1,24 +0,0 @@ -package store - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) requireInitializedEnvironment(ctx context.Context, tenant, session pgtype.UUID) error { - ready, err := s.queries.GetSessionInitializationReady(ctx, sqlc.GetSessionInitializationReadyParams{TenantID: tenant, ID: session}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if !ready { - return ErrNotFound - } - return nil -} diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go deleted file mode 100644 index 09089c09d..000000000 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package store_test - -import ( - "archive/zip" - "bytes" - "context" - "encoding/json" - "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "reflect" - "strings" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type initializingProvider struct { - lifecycleProvider - initializationPeer -} - -func (p *initializingProvider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - info, err := p.lifecycleProvider.Create(ctx, b) - if err == nil { - err = p.connect(b) - } - return info, err -} -func (p *initializingProvider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - return p.initializationPeer.RunCommand(ctx, r, c) -} - -func TestEnvironmentInitializationCompletionUnknownAndRestart(t *testing.T) { - for _, mode := range []string{"complete", "restart", "uncertain", "setup-complete", "setup-restart", "setup-uncertain"} { - t.Run(mode, func(t *testing.T) { - setupOnly := strings.HasPrefix(mode, "setup-") - mode = strings.TrimPrefix(mode, "setup-") - expectedSteps := 2 - _, pool := store.NewManagedTestStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) - if err != nil { - t.Fatal(err) - } - s := store.NewWithCredentialCipher(pool, cipher) - tenant := uuid.NewString() - input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} - if setupOnly { - input.InitialFiles = nil - input.Initialization = store.EnvironmentSetup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []store.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} - expectedSteps = 4 - } - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - env, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - if mode == "restart" { - if _, err := pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", env.ID); err != nil { - t.Fatal(err) - } - } - p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} - p.apply = func(_ proto.RuntimePreparePayload, _ []byte) proto.RuntimePrepareResultPayload { - if _, err := s.GetSessionDevice(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { - t.Error("premature file access", err) - } - if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { - t.Error("premature execution", err) - } - if mode == "uncertain" { - return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} - } - return completedInitialization(proto.RuntimePreparePayload{}, nil) - } - key := uuid.NewString() - w, stop := managedWorkerMode(t, s, key, p, false, true) - if mode == "restart" { - awaitInitialization(t, s, tenant, env.ID, "failed") - if p.writes.Load() != 0 { - t.Fatal("recovered unknown operation replayed") - } - return - } - owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) - if err != nil { - t.Fatal(err) - } - if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || !ok { - t.Fatal("preparation blocked authentication", err) - } - time.Sleep(350 * time.Millisecond) - if initializationState(t, s, tenant, env.ID) != "pending" || p.writes.Load() != 0 { - t.Fatal("missing socket consumed initialization") - } - p.deferred = false - if err := p.connect(sandbox.Bootstrap{DeviceID: owner.DeviceID, Credential: p.credential}); err != nil { - t.Fatal(err) - } - want := "complete" - if mode == "uncertain" { - want = "failed" - } - awaitInitialization(t, s, tenant, env.ID, want) - if mode == "complete" { - if int(p.writes.Load()) != expectedSteps { - t.Fatal("missing operations", p.writes.Load()) - } - if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { - t.Fatal("completed preparation blocked", err) - } - stop() - _, _ = managedWorkerMode(t, s, key, p, false, true) - time.Sleep(350 * time.Millisecond) - if int(p.writes.Load()) != expectedSteps { - t.Fatal("completed preparation replayed") - } - } else if p.writes.Load() != 1 { - t.Fatal("unknown operation replayed", p.writes.Load()) - } - p.mu.Lock() - kills := p.kills - p.mu.Unlock() - if kills != 0 || p.commandCalls.Load() != 0 { - t.Fatal("preparation changed compute lifecycle", kills, p.commandCalls.Load()) - } - }) - } -} - -func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { - s := hostedFailureStore(t) - var archive bytes.Buffer - writer := zip.NewWriter(&archive) - for path, body := range map[string]string{"proof/.codex-plugin/plugin.json": `{"name":"plugin","description":"A plugin.","skills":"./skills"}`, "proof/skills/example/SKILL.md": "---\nname: plugin-proof\ndescription: A plugin Skill.\n---\nProof."} { - file, err := writer.Create(path) - if err != nil { - t.Fatal(err) - } - if _, err := file.Write([]byte(body)); err != nil { - t.Fatal(err) - } - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - tenant := uuid.NewString() - fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) - session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ - InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, - Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, - }) - provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} - var actions []string - var actionsMu sync.Mutex - provider.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { - if request.SessionID != session.ID || request.EnvironmentID != environment.ID { - t.Error("Runtime identity changed") - } - action := request.Action - if request.Initialization != nil { - action = request.Initialization.Action - } - if action == "file" && !bytes.Equal(data, fileBody) { - t.Error("initial bytes changed") - } - actionsMu.Lock() - actions = append(actions, action) - actionsMu.Unlock() - return completedInitialization(request, data) - } - key := uuid.NewString() - worker, _ := managedWorkerMode(t, s, key, provider, false, true) - if _, err := worker.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { - t.Fatal(err) - } - awaitInitialization(t, s, tenant, environment.ID, "complete") - actionsMu.Lock() - defer actionsMu.Unlock() - expected := []string{"file", "configure", "skill", "plugin", "npm", "python", "setup", "finalize"} - if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { - t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) - } - allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) - if err != nil || initializationState(t, s, allocation.TenantID, allocation.EnvironmentID) != "complete" { - t.Fatal("initialization incomplete", allocation, err) - } -} diff --git a/services/agents-api/internal/store/runtime_observation.go b/services/agents-api/internal/store/runtime_observation.go deleted file mode 100644 index 0df01ed63..000000000 --- a/services/agents-api/internal/store/runtime_observation.go +++ /dev/null @@ -1,48 +0,0 @@ -package store - -import ( - "context" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5/pgtype" -) - -// RecordRuntimeObservation changes diagnostics only for the observed lifecycle revision. -// It never releases ownership, changes public readiness or authorizes replacement. -func (s *Store) RecordRuntimeObservation(ctx context.Context, owner RuntimeAllocation, diagnostic string) error { - switch diagnostic { - case "", "node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable": - default: - return ErrInvalidInput - } - if owner.NodeID == "" { - return nil - } - _, err := s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { - if row.ComputeRevision != owner.ComputeRevision || row.State != owner.State || row.State == "released" { - return row, nil - } - err := q.SetRuntimeObservation(ctx, sqlc.SetRuntimeObservationParams{ID: row.ID, ComputeRevision: owner.ComputeRevision, State: owner.State, ObservationError: diagnostic}) - return row, err - }) - return err -} -func (s *Store) RuntimeNodeAvailable(ctx context.Context, node string) (bool, error) { - id, err := parseConnectionGeneration(node) - if err != nil { - return false, err - } - return runtimeNodeAvailable(ctx, s.queries, id) -} -func runtimeNodeAvailable(ctx context.Context, q *sqlc.Queries, node pgtype.UUID) (bool, error) { - nodes, err := q.ListRuntimeNodes(ctx, pgtype.UUID{}) - if err != nil { - return false, err - } - for _, n := range nodes { - if n.ID == node { - return n.Online, nil - } - } - return false, nil -} diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go deleted file mode 100644 index 40796a341..000000000 --- a/services/agents-api/internal/store/sandbox_deployment_setup.go +++ /dev/null @@ -1,194 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "net" - "net/url" - "strconv" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -var ErrSandboxDeploymentConflict = errors.New("sandbox deployment is already configured differently") - -type SandboxDeploymentSetupRequest = sandbox.Selection - -// SandboxSetup is the immutable configuration selected by the deployment admin. -// An empty Provider means that Web setup has not yet selected an adapter. -type SandboxSetup struct { - Specification sandbox.DeploymentSpec - InstallationID, Provider, BackendFingerprint string - Generation uint64 - Mode string - AdmissionPaused bool - E2B *sandbox.E2BConfiguration - IdleSeconds, RetentionSeconds int64 -} - -func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { - ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) - defer cancel() - d, err := s.queries.GetRuntimeDeployment(ctx) - if err != nil { - return SandboxSetup{}, err - } - return s.sandboxSetup(d) -} - -func (s *Store) sandboxSetup(d sqlc.RuntimeDeployment) (SandboxSetup, error) { - if !d.WebManaged { - return SandboxSetup{}, ErrSandboxDeploymentConflict - } - result := SandboxSetup{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, BackendFingerprint: d.BackendFingerprint, IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Generation: uint64(d.Generation), Mode: d.Mode, AdmissionPaused: d.AdmissionPaused} - if err := json.Unmarshal(d.Specification, &result.Specification); err != nil { - return SandboxSetup{}, err - } - if d.ProviderKind != "" { - if err := providers.ValidateSpecification(d.ProviderKind, result.Specification); err != nil { - return SandboxSetup{}, err - } - } - if providers.UsesCredential(d.ProviderKind) { - credential, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, result.InstallationID, result.Generation) - if err != nil { - return SandboxSetup{}, ErrSandboxCredentialUnavailable - } - selection, err := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, DeploymentSpec: result.Specification, E2B: &sandbox.E2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) - if err != nil { - return SandboxSetup{}, ErrSandboxDeploymentConflict - } - result.E2B = selection.E2B - } - return result, nil -} - -// ClaimWebSandboxDeployment runs exactly once per execution-owner startup. It -// reserves the installation before selection and fences previous node presence. -func (s *Store) ClaimWebSandboxDeployment(ctx context.Context, installationID string) error { - id, err := parseConnectionGeneration(installationID) - if err != nil || s.executionLease == nil { - return ErrInvalidInput - } - ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) - defer cancel() - return s.executionLease.transaction(ctx, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - d, err := q.LockRuntimeDeployment(ctx) - if err != nil { - return err - } - if d.InstallationID.Valid { - if !d.WebManaged || d.InstallationID != id { - return ErrSandboxDeploymentConflict - } - } else { - resources, err := q.CountRuntimeDeploymentResources(ctx) - if err != nil { - return err - } - if resources.Allocations != 0 || resources.Pending != 0 { - return ErrSandboxDeploymentConflict - } - } - if d.ProviderKind != "" { - if _, err := deploymentSpecification(d); err != nil { - return err - } - } - return q.ClaimWebSandboxDeployment(ctx, id) - }) -} - -// ValidateSandboxCoreURL accepts a canonical public origin, never a path or -// credential. Plain HTTP is reserved for explicit loopback development hosts. -// OAC_PUBLIC_URL must pass it. -func ValidateSandboxCoreURL(value string) error { - u, err := url.Parse(value) - if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { - return ErrInvalidInput - } - if strings.ContainsAny(u.Host, "\\% \t\r\n") || strings.HasSuffix(u.Host, ":") { - return ErrInvalidInput - } - if port := u.Port(); port != "" { - n, err := strconv.Atoi(port) - if err != nil || n < 1 || n > 65535 || strconv.Itoa(n) != port { - return ErrInvalidInput - } - } - loopback := u.Hostname() == "localhost" - if ip := net.ParseIP(u.Hostname()); ip != nil { - loopback = ip.IsLoopback() - } else { - if len(u.Hostname()) > 253 || strings.ContainsAny(u.Host, "[]") { - return ErrInvalidInput - } - for _, label := range strings.Split(u.Hostname(), ".") { - if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return ErrInvalidInput - } - for _, char := range label { - if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { - return ErrInvalidInput - } - } - } - } - if u.Scheme != "https" && !(u.Scheme == "http" && loopback) { - return ErrInvalidInput - } - return nil -} - -// LoopbackOrigin reports whether a validated origin names a loopback host, which -// nothing outside the Core host can reach. -func LoopbackOrigin(value string) bool { - u, err := url.Parse(value) - if err != nil { - return false - } - if ip := net.ParseIP(u.Hostname()); ip != nil { - return ip.IsLoopback() - } - return u.Hostname() == "localhost" -} - -func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDeploymentView { - result := RuntimeDeploymentView{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: publicURL, OwnerEpoch: uint64(d.OwnerEpoch), Generation: uint64(d.Generation), Mode: d.Mode} - if len(d.Specification) > 0 && string(d.Specification) != "{}" { - var spec sandbox.DeploymentSpec - if json.Unmarshal(d.Specification, &spec) == nil { - result.Specification = &spec - result.SpecificationDigest = spec.Digest(d.ProviderKind) - } - } - if providers.UsesCredential(d.ProviderKind) { - selection, _ := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, E2B: &sandbox.E2BConfiguration{Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) - apiURL, domain := selection.E2B.APIURL, selection.E2B.Domain - result.E2B = &SandboxE2BView{Template: d.E2bTemplate, APIURL: apiURL, Domain: domain, CredentialConfigured: len(d.E2bCredential) > 0, - TemplateBuild: SandboxE2BTemplateBuildView{Resources: SandboxTemplateResources{ - CPUs: optionalInt32(d.E2bTemplateCpus), MemoryMiB: optionalInt32(d.E2bTemplateMemoryMib), RootDiskMiB: optionalInt32(d.E2bTemplateRootDiskMib)}}} - if d.E2bTemplateBuildStatus.Valid { - status := d.E2bTemplateBuildStatus.String - result.E2B.TemplateBuild.Status = &status - } - } - if providers.SupportsCheckpoint(d.ProviderKind) { - result.Suspension = &SandboxSuspensionView{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} - } - return result -} - -func optionalInt32(value pgtype.Int4) *int32 { - if !value.Valid { - return nil - } - return &value.Int32 -} diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go deleted file mode 100644 index c6785282a..000000000 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ /dev/null @@ -1,429 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "strings" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func e2bSelection() SandboxDeploymentSetupRequest { - return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} -} - -func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - input := e2bSelection() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" - view, err := w.InitializeSandboxDeployment(t.Context(), id, input) - if err != nil || view.E2B == nil || view.E2B.APIURL != input.E2B.APIURL || view.E2B.Domain != input.E2B.Domain { - t.Fatal("custom endpoint was not returned", view, err) - } - setup, err := s.GetSandboxSetup(t.Context()) - if err != nil || setup.E2B == nil || setup.E2B.APIURL != input.E2B.APIURL || setup.E2B.Domain != input.E2B.Domain { - t.Fatal("custom endpoint was not persisted", setup, err) - } - change := e2bSelection() - change.E2B.Template = input.E2B.Template - update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: change, ExpectedGeneration: view.Generation} - changed, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update) - if err != nil || changed.Generation != view.Generation+1 || changed.E2B == nil || changed.E2B.APIURL != "https://api.e2b.app" || changed.E2B.Domain != "e2b.app" { - t.Fatal("online endpoint switch failed", changed, err) - } -} - -func TestSandboxResetClearsCustomE2BEndpoint(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - installation := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { - t.Fatal(err) - } - input := e2bSelection() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" - configured, err := w.InitializeSandboxDeployment(t.Context(), installation, input) - if err != nil { - t.Fatal(err) - } - ctx := SandboxResetTestContext(t.Context()) - reset, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: configured.Generation, Clear: "force"}) - if err != nil { - t.Fatal(err) - } - empty, err := w.CompleteSandboxReset(ctx, installation, configured.Generation, reset.Reset.RequestedAt) - if err != nil || empty.Provider != "" || empty.Reset != nil || empty.Generation != configured.Generation+1 { - t.Fatal("custom endpoint blocked reset completion", empty, err) - } - var apiURL, domain string - if err := pool.QueryRow(t.Context(), "SELECT e2b_api_url, e2b_domain FROM runtime_deployment").Scan(&apiURL, &domain); err != nil || apiURL != "" || domain != "" { - t.Fatal("reset retained custom endpoint", apiURL, domain, err) - } -} - -func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{4}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - input := e2bSelection() - view, err := w.InitializeSandboxDeployment(t.Context(), id, input) - if err != nil || view.Generation != 1 || view.Mode != "direct" || view.E2B == nil || !view.E2B.CredentialConfigured { - t.Fatal("direct setup", view, err) - } - raw, _ := json.Marshal(view) - if bytes.Contains(raw, []byte(input.E2B.APIKey)) { - t.Fatal("credential in public view") - } - var ciphertext []byte - if err := pool.QueryRow(t.Context(), "SELECT e2b_credential FROM runtime_deployment").Scan(&ciphertext); err != nil || bytes.Contains(ciphertext, []byte(input.E2B.APIKey)) { - t.Fatal("credential not encrypted", err) - } - setup, err := s.GetSandboxSetup(t.Context()) - if err != nil || setup.E2B.APIKey != input.E2B.APIKey { - t.Fatal("internal credential unavailable", err) - } - if _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 8}); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("cloud enrolled a machine", err) - } - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - environment := session.Environment.ID - nodes, err := w.ListRuntimeLifecycleNodes(t.Context()) - if err != nil || len(nodes) != 1 || nodes[0] != "" { - t.Fatal("cloud lifecycle requires node", nodes, err) - } - owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment, id, device.HashCredential(uuid.NewString())) - if err != nil || owner.NodeID != "" { - t.Fatal(owner, err) - } - credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) - if err != nil || !ok || credential.RuntimeAllocationID != owner.ID || credential.RuntimeNodeID != "" { - t.Fatal("direct bootstrap lost managed identity", err) - } - if _, err := pool.Exec(t.Context(), "UPDATE runtime_allocations SET kept_at=clock_timestamp()-interval '2 hours' WHERE id=$1", owner.ID); err != nil { - t.Fatal(err) - } - observed, err := w.GetRuntimeAllocation(t.Context(), tenant, environment) - if err != nil || observed.Expired { - t.Fatal("cloud inherited legacy node-less expiry", err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { - t.Fatal(err) - } - update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1} - if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update); !errors.Is(err, ErrSandboxResetInProgress) { - t.Fatal("reset allowed switch", err) - } - if _, err := w.RequestRuntimeCleanup(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { - t.Fatal("unsettled create released", err) - } - if _, err := w.SettleRuntimeCreation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { - t.Fatal(err) - } - changed, err := resetAndSelect(t, w, id, update.ExpectedGeneration, update.SandboxDeploymentSetupRequest) - if err != nil || changed.Generation != 3 || changed.Mode != "nodes" || changed.Reset != nil || changed.E2B != nil || changed.Resources != (SandboxDeploymentResources{}) { - t.Fatal("clean switch", changed, err) - } - if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), id, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("stale resume accepted", err) - } - if _, err := s.GetSession(t.Context(), tenant, session.ID); err != nil { - t.Fatal("historical Session lost", err) - } -} - -func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - if _, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { - t.Fatal(err) - } - token, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4})) - if err != nil { - t.Fatal(err) - } - node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: SandboxDeploymentTestSpec("docker").Digest("docker"), NodeID: uuid.NewString(), Name: "Machine", Provider: "docker", Credential: strings.Repeat("c", 64), BackendFingerprint: strings.Repeat("b", 64)} - if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { - t.Fatal(err) - } - unused, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 8})) - if err != nil { - t.Fatal(err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { - t.Fatal(err) - } - // AdmissionPaused rejects a valid enrollment without consuming it. Authentication - // still precedes deployment details for invalid or retired credentials. - spareNode := node - spareNode.NodeID = uuid.NewString() - if _, err := s.EnrollRuntimeNode(t.Context(), unused, spareNode); !errors.Is(err, ErrSandboxResetInProgress) { - t.Fatal("reset accepted enrollment", err) - } - var consumed bool - if err := pool.QueryRow(t.Context(), "SELECT consumed_at IS NOT NULL FROM runtime_node_enrollments WHERE token_sha256=$1", runtimeTokenDigest(unused)).Scan(&consumed); err != nil || consumed { - t.Fatal("maintenance consumed enrollment", err) - } - spareNode.Provider = "microsandbox" - if _, err := s.EnrollRuntimeNode(t.Context(), strings.Repeat("invalid", 8), spareNode); !errors.Is(err, ErrRuntimeNodeCredential) { - t.Fatal("invalid token disclosed deployment validation", err) - } - spareNode.Provider = "docker" - input := e2bSelection() - if _, err := resetAndSelect(t, w, id, 1, input); err != nil { - t.Fatal(err) - } - - if _, err := s.EnrollRuntimeNode(t.Context(), unused, spareNode); !errors.Is(err, ErrRuntimeNodeCredential) { - t.Fatal("retired token did not reject before cloud deployment validation", err) - } - if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeNodeCredential) { - t.Fatal("old node credential survived", err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 3}); err != nil { - t.Fatal(err) - } - if _, err := resetAndSelect(t, w, id, 3, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { - t.Fatal(err) - } - if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), id, 5); err != nil { - t.Fatal(err) - } - node.NodeID = uuid.NewString() - if _, err := s.EnrollRuntimeNode(t.Context(), unused, node); !errors.Is(err, ErrRuntimeNodeCredential) { - t.Fatal("old enrollment survived roundtrip", err) - } - nodes, err := s.ListRuntimeNodes(t.Context()) - if err != nil || len(nodes) != 0 { - t.Fatal("retired nodes reappeared", err) - } -} - -func TestSandboxResetSerializesFreshDirectSessions(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{6}, 32)) - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - input := e2bSelection() - if _, err := w.InitializeSandboxDeployment(t.Context(), id, input); err != nil { - t.Fatal(err) - } - var wg sync.WaitGroup - for range 12 { - wg.Add(1) - go func() { - defer wg.Done() - _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())) - if err != nil && !errors.Is(err, ErrSandboxResetAdmission) && !errors.Is(err, ErrRuntimeNodeUnavailable) { - t.Error(err) - } - }() - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { - t.Fatal(err) - } - wg.Wait() - for range 3 { - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrSandboxResetAdmission) { - t.Fatal("fresh creation bypassed reset", err) - } - } - view, err := s.GetRuntimeDeployment(t.Context()) - if err != nil { - t.Fatal(err) - } - _, err = w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1}) - if view.Resources.Pending > 0 && !errors.Is(err, ErrSandboxResetInProgress) { - t.Fatal("committed pending Session bypassed switch guard", err) - } -} - -func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - installation := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { - t.Fatal(err) - } - selection := e2bSelection() - if _, err := w.InitializeSandboxDeployment(t.Context(), installation, selection); err != nil { - t.Fatal(err) - } - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - if _, err := w.ReleaseAbsentRuntimeCreation(t.Context(), owner); err != nil { - t.Fatal(err) - } - // A separate completed Session supplies public Items without calling a model. - history, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) - if err != nil { - t.Fatal(err) - } - input, err := s.SubmitMessage(t.Context(), tenant, history.ID, "history", json.RawMessage(`{"text":"retained request"}`)) - if err != nil { - t.Fatal(err) - } - if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); err != nil { - t.Fatal(err) - } - if err := w.AppendTurnEvents(t.Context(), tenant, history.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained answer"}`)}}); err != nil { - t.Fatal(err) - } - if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}); err != nil { - t.Fatal(err) - } - items, err := s.ListItems(t.Context(), tenant, history.ID, "", 100, true) - if err != nil || len(items.Items) != 2 { - t.Fatal("history fixture", err) - } - before, _ := json.Marshal(items) - var allocationBefore []byte - if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(a) FROM runtime_allocations a WHERE id=$1", owner.ID).Scan(&allocationBefore); err != nil { - t.Fatal(err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { - t.Fatal(err) - } - if _, err := resetAndSelect(t, w, installation, 1, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { - t.Fatal(err) - } - items, err = s.ListItems(t.Context(), tenant, history.ID, "", 100, true) - if err != nil { - t.Fatal(err) - } - after, _ := json.Marshal(items) - if !bytes.Equal(before, after) { - t.Fatal("switch rewrote public Item history") - } - var allocationAfter []byte - if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(a) FROM runtime_allocations a WHERE id=$1", owner.ID).Scan(&allocationAfter); err != nil { - t.Fatal(err) - } - if !bytes.Equal(allocationBefore, allocationAfter) { - t.Fatal("switch rewrote released allocation ownership") - } - for _, id := range []string{session.ID, history.ID} { - if _, err := s.GetSession(t.Context(), tenant, id); err != nil { - t.Fatal("switch lost undeleted Session", err) - } - } -} - -// Migration 000069 leaves a node-backed selection saved before specifications -// with an empty specification and its nodes with empty digests. The retained -// node reconnects to drain resources; fresh admission and node configuration -// stay closed until an administrator replaces the selection. -func TestUnspecifiedNodeDeploymentRejectedWithoutMutation(t *testing.T) { - _, pool := newManagedTestStore(t) - cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) - s := NewWithCredentialCipher(pool, cipher) - w := executionLease(t, s).Store() - id := uuid.NewString() - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { - t.Fatal(err) - } - spec := SandboxDeploymentTestSpec("docker") - selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker"} - if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { - t.Fatal(err) - } - token, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4})) - if err != nil { - t.Fatal(err) - } - node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: spec.Digest("docker"), NodeID: uuid.NewString(), Name: "Legacy", Provider: "docker", Credential: strings.Repeat("l", 64), BackendFingerprint: strings.Repeat("b", 64)} - if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET specification='{}'"); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "UPDATE runtime_nodes SET specification_digest='', deployment_generation=0"); err != nil { - t.Fatal(err) - } - - if _, err := s.GetSandboxSetup(t.Context()); err == nil { - t.Fatal("missing deployment specification accepted") - } - if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { - t.Fatal("unspecified node authenticated", err) - } - if _, err := s.RuntimeNodeConfiguration(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { - t.Fatal("node configuration served without a specification", err) - } - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrEnvironmentUnavailable) { - t.Fatal("unspecified deployment admitted a fresh sandbox", err) - } - if _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("unspecified deployment issued an enrollment token", err) - } - - epoch := managerEpoch(t, s) - if err := w.ClaimWebSandboxDeployment(t.Context(), id); err == nil { - t.Fatal("unsupported installation claimed") - } - if managerEpoch(t, s) != epoch { - t.Fatal("refused startup changed owner epoch") - } - var specification string - if err := pool.QueryRow(t.Context(), "SELECT specification::text FROM runtime_deployment").Scan(&specification); err != nil || specification != "{}" { - t.Fatal("deployment silently repaired", specification, err) - } -} diff --git a/services/agents-api/internal/store/sandbox_generations_test.go b/services/agents-api/internal/store/sandbox_generations_test.go deleted file mode 100644 index 09d749eed..000000000 --- a/services/agents-api/internal/store/sandbox_generations_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/stdlib" - "github.com/pressly/goose/v3" - "os" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { - s, w, view, input := webSpecificationFixture(t, "e2b") - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - ctx := SandboxResetTestContext(t.Context()) - oldTemplate := input.E2B.Template - input.E2B.Template = "next:" + uuid.NewString() - input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" - input.Resources.CPUs++ - changed, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) - if err != nil || changed.Generation != 2 || changed.OwnerEpoch != view.OwnerEpoch || changed.Rollout.PreviousGenerationSandboxes != 1 || changed.Rollout.State != "settled" { - t.Fatal(changed, err) - } - assertSandboxSnapshotEquivalent(t, s.pool) - ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} - retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" || retained.Specification.Resources.CPUs == input.Resources.CPUs { - t.Fatal(retained, err) - } - input.E2B.APIKey = "replacement-secret" - input.E2B.ReplaceCredential = true - changed, err = w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 2}) - if err != nil || changed.Generation != 3 || changed.OwnerEpoch != view.OwnerEpoch { - t.Fatal(changed, err) - } - retained, err = s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" { - t.Fatal("old generation did not use committed key", err) - } - if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_allocations SET deployment_generation=3 WHERE id=$1`, owner.ID); err == nil { - t.Fatal("ownership generation was mutable") - } - if err = w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - generations, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "https://api.e2b.app" { - t.Fatal(generations, err) - } - if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err = w.SettleRuntimeCreation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err = w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if err = w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - generations, err = s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 0 { - t.Fatal(generations, err) - } - historical, err := s.GetRuntimeAllocation(t.Context(), tenant, owner.EnvironmentID) - if err != nil || historical.DeploymentGeneration != 1 { - t.Fatal("historical generation erased", err) - } -} - -func TestE2BRetainedCustomEndpointAfterOnlineSwitch(t *testing.T) { - s, w, view, input := webSpecificationFixture(t, "e2b") - ctx := SandboxResetTestContext(t.Context()) - input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" - custom, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation}) - if err != nil || custom.Generation != 2 { - t.Fatal(custom, err) - } - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.APIURL, input.E2B.Domain = "", "" - current, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: custom.Generation}) - if err != nil || current.Generation != 3 || current.E2B.APIURL != "https://api.e2b.app" { - t.Fatal(current, err) - } - ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} - retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) - if err != nil || retained.Generation != 2 || retained.E2B.APIURL != "https://sandbox.example.com" || retained.E2B.Domain != "sandbox.example.com" { - t.Fatal(retained, err) - } - generations, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(generations) != 1 || generations[0].E2B.APIURL != "https://sandbox.example.com" { - t.Fatal(generations, err) - } -} - -func TestE2BChangeClassifierOmittedKeyAndExplicitSameKey(t *testing.T) { - _, w, view, input := webSpecificationFixture(t, "e2b") - key := input.E2B.APIKey - input.E2B.APIKey = "" - input.Resources = sandbox.Resources{} - resolved, noOp, err := w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) - if err != nil || !noOp || resolved.E2B.APIKey != key || resolved.Resources.CPUs == 0 { - t.Fatal(noOp, err) - } - input.E2B.APIKey = key - input.E2B.ReplaceCredential = true - _, noOp, err = w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) - if err != nil || noOp { - t.Fatal("explicit same key skipped verification", err) - } - invalid := input - invalid.Runtime = &sandbox.RuntimeRelease{} - if _, _, err := w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: invalid, ExpectedGeneration: 1}); err == nil { - t.Fatal("omitted resources erased forbidden Runtime input") - } - _, _, err = w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 0}) - var stale *SandboxGenerationStaleError - if !errors.As(err, &stale) { - t.Fatal("stale did not precede no-op", err) - } -} - -func TestGenerationPinRetainsOfflineZeroResourceFallback(t *testing.T) { - s, w, view, _ := webSpecificationFixture(t, "docker") - node := specificationNode(t, s, view) - if _, err := s.pool.Exec(t.Context(), `UPDATE runtime_nodes SET connection_id=NULL WHERE id=$1`, node.NodeID); err != nil { - t.Fatal(err) - } - // PR-N will make this transition through its generation protocol. This fixture - // isolates the persistence invariant without enabling node online PUT in PR-G. - tx, err := s.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(t.Context()) - q := s.queries.WithTx(tx) - if _, err = q.LockRuntimeDeployment(t.Context()); err != nil { - t.Fatal(err) - } - if err = q.RetainSandboxGeneration(t.Context()); err != nil { - t.Fatal(err) - } - if _, err = tx.Exec(t.Context(), `UPDATE runtime_deployment SET generation=2`); err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if err = w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - rows, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(rows) != 1 { - t.Fatal("offline pin was collected", rows, err) - } - nodes, err := s.ListRuntimeNodes(t.Context()) - if err != nil || len(nodes) != 1 || nodes[0].Rollout.State != "unknown" || nodes[0].Rollout.ReadyGeneration == nil || *nodes[0].Rollout.ReadyGeneration != 1 { - t.Fatal(nodes, err) - } - if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_nodes SET removed_at=clock_timestamp(),ready_generation=NULL WHERE id=$1`, node.NodeID); err != nil { - t.Fatal(err) - } - if err = w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - rows, err = s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(rows) != 0 { - t.Fatal(rows, err) - } -} - -func TestPendingPlacementGenerationSurvivesRepeatedUpdates(t *testing.T) { - s, w, view, _ := webSpecificationFixture(t, "docker") - node := specificationNode(t, s, view) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - for generation := uint64(1); generation <= 2; generation++ { - tx, err := s.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - q := s.queries.WithTx(tx) - if _, err = q.LockRuntimeDeployment(t.Context()); err != nil { - t.Fatal(err) - } - if err = q.RetainSandboxGeneration(t.Context()); err != nil { - t.Fatal(err) - } - if _, err = tx.Exec(t.Context(), `UPDATE runtime_deployment SET generation=generation+1`); err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - } - assertSandboxSnapshotEquivalent(t, s.pool) - owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - if owner.DeploymentGeneration != 1 || owner.NodeID != node.NodeID { - t.Fatal("pending allocation rebound to newest generation", owner) - } - if err := w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - rows, err := s.SandboxGenerationPage(t.Context(), -1) - if err != nil || len(rows) != 1 || rows[0].Generation != 1 { - t.Fatal(rows, err) - } - if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_placements SET deployment_generation=3 WHERE environment_id=$1`, owner.EnvironmentID); err == nil { - t.Fatal("placement generation changed") - } -} - -func TestGenerationMigrationBackfillsAndRejectsLossyDowngrade(t *testing.T) { - db, migrations := runtimeNamesMigrationSchema(t) - ctx := t.Context() - if _, err := migrations.UpTo(ctx, 80); err != nil { - t.Fatal(err) - } - installation, node := uuid.NewString(), uuid.NewString() - if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET installation_id=$1,backend_fingerprint=repeat('a',64),provider_kind='docker',mode='nodes',generation=1`, installation); err != nil { - t.Fatal(err) - } - if _, err := db.ExecContext(ctx, `INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,deployment_generation) VALUES($1,$2,'old',repeat('a',64),repeat('b',64),1,1,1)`, node, installation); err != nil { - t.Fatal(err) - } - session, tenant, environment, device, allocation := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() - exec := func(query string, args ...any) { - t.Helper() - if _, err := db.ExecContext(ctx, query, args...); err != nil { - t.Fatal(err) - } - } - exec(`INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) VALUES($1,$2,'codex','old','old','{}')`, session, tenant) - exec(`INSERT INTO environments(id,session_id) VALUES($1,$2)`, environment, session) - exec(`INSERT INTO devices(id,tenant_id,name,credential_hash) VALUES($1,$2,'old',repeat('b',64))`, device, tenant) - exec(`INSERT INTO runtime_placements(environment_id,node_id) VALUES($1,$2)`, environment, node) - exec(`INSERT INTO runtime_allocations(id,environment_id,device_id,provider_key,node_id) VALUES($1,$2,$3,$4,$5)`, allocation, environment, device, installation, node) - if _, err := migrations.UpTo(ctx, 81); err != nil { - t.Fatal(err) - } - for _, table := range []string{"runtime_allocations", "runtime_placements"} { - var generation int64 - if err := db.QueryRowContext(ctx, `SELECT deployment_generation FROM `+table).Scan(&generation); err != nil || generation != 1 { - t.Fatal("inexact migration backfill", table, generation, err) - } - } - var pin int64 - if err := db.QueryRowContext(ctx, `SELECT ready_generation FROM runtime_nodes WHERE id=$1`, node).Scan(&pin); err != nil || pin != 1 { - t.Fatal(pin, err) - } - if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET generation=2`); err != nil { - t.Fatal(err) - } - if _, err := migrations.DownTo(ctx, 80); err == nil { - t.Fatal("downgrade erased offline serving pin") - } - if _, err := db.ExecContext(ctx, `UPDATE runtime_nodes SET removed_at=clock_timestamp(),ready_generation=NULL`); err != nil { - t.Fatal(err) - } - if _, err := migrations.DownTo(ctx, 80); err == nil { - t.Fatal("downgrade discarded held allocation/placement after pin removal") - } - exec(`UPDATE runtime_allocations SET state='released',released_at=clock_timestamp(),create_settled=true`) - if _, err := migrations.DownTo(ctx, 80); err == nil { - t.Fatal("downgrade discarded unreleased placement") - } - exec(`UPDATE runtime_placements SET released_at=clock_timestamp()`) - if _, err := migrations.DownTo(ctx, 80); err != nil { - t.Fatal("settled downgrade failed", err) - } -} - -func TestGenerationDowngradeRefusesOldAllocation(t *testing.T) { - s, w, view, input := webSpecificationFixture(t, "e2b") - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - input.E2B.Template = "next:" + uuid.NewString() - if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}); err != nil { - t.Fatal(err) - } - db := sql.OpenDB(stdlib.GetConnector(*s.pool.Config().ConnConfig)) - defer db.Close() - migrations, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) - if err != nil { - t.Fatal(err) - } - if _, err = migrations.DownTo(t.Context(), 80); err == nil { - t.Fatal("downgrade erased old owned allocation") - } - // Earlier down migrations can commit before the generation guard vetoes - // downgrade. Restore the current schema before invoking current Store code. - if _, err = migrations.Up(t.Context()); err != nil { - t.Fatal(err) - } - if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err = w.SettleRuntimeCreation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err = w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { - t.Fatal(err) - } - if _, err = migrations.DownTo(t.Context(), 80); err != nil { - t.Fatal("released history prevented safe downgrade", err) - } -} - -func TestGenerationUpdateSerializesWithAllocationAdmission(t *testing.T) { - s, w, view, input := webSpecificationFixture(t, "e2b") - for generation := uint64(1); generation <= 6; generation++ { - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - input.E2B.Template = "next:" + uuid.NewString() - start := make(chan struct{}) - changed := make(chan error, 1) - go func() { - <-start - _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: generation}) - changed <- err - }() - close(start) - owner := archiveAllocation(t, w, tenant, session, view.InstallationID) - if err := <-changed; err != nil { - t.Fatal(err) - } - if owner.DeploymentGeneration != generation && owner.DeploymentGeneration != generation+1 { - t.Fatal("allocation bound unrelated generation", owner.DeploymentGeneration) - } - if err := w.CollectSandboxGenerations(t.Context()); err != nil { - t.Fatal(err) - } - setup, err := s.GetSandboxAllocationSetup(t.Context(), sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID}) - if err != nil || setup.Generation != owner.DeploymentGeneration { - t.Fatal("committed allocation lost immutable routing", err) - } - } -} - -func TestNodeRolloutSeparatesOfflinePinAndTargetReadiness(t *testing.T) { - for _, tc := range []struct { - name string - online, ready bool - enrolled, pin, target int64 - targetState, diagnostic, state string - }{ - {"offline pin", false, true, 1, 1, 2, "ready", "", "unknown"}, - {"old serving", true, true, 1, 1, 2, "ready", "", "update_required"}, - {"current serving", true, true, 2, 2, 2, "ready", "", "ready"}, - {"current failed", true, false, 2, 2, 2, "failed", "kvm_unavailable", "failed"}, - {"current unknown", true, false, 2, 2, 2, "", "", "unknown"}, - } { - t.Run(tc.name, func(t *testing.T) { - got := nodeRollout(sqlc.ListRuntimeNodesRow{Online: tc.online, ProviderReady: tc.ready, DeploymentGeneration: tc.enrolled, ReadyGeneration: pgtype.Int8{Int64: tc.pin, Valid: true}, TargetGeneration: tc.target, ProtocolVersion: 1, TargetState: tc.targetState, TargetDiagnostic: tc.diagnostic}) - if got.State != tc.state || got.Diagnostic != tc.diagnostic || got.ReadyGeneration == nil || *got.ReadyGeneration != uint64(tc.pin) { - t.Fatal(got) - } - }) - } -} - -func TestSandboxSnapshotRolloutEquivalence(t *testing.T) { - s, w, view, input := webSpecificationFixture(t, "docker") - node := specificationNode(t, s, view) - for _, state := range []string{"ready", "preparing", "failed", "unconfirmed", "offline", "update_required"} { - t.Run(state, func(t *testing.T) { - connection := onlineManagerNode(t, s, node.NodeID) - want := SandboxRolloutNodes{} - wantState := "settled" - switch state { - case "ready": - want.Ready = 1 - case "preparing": - generationHeartbeat(t, s, node, connection, view, "preparing") - want.Preparing = 1 - wantState = "preparing" - case "failed": - generationHeartbeat(t, s, node, connection, view, "failed") - want.Failed = 1 - case "unconfirmed": - connection = uuid.NewString() - if err := s.ConnectRuntimeNode(t.Context(), node.NodeID, connection, view.OwnerEpoch); err != nil { - t.Fatal(err) - } - if err := s.HeartbeatRuntimeNodeGenerations(t.Context(), node.NodeID, connection, view.OwnerEpoch, RuntimeNodeHealth{}, nil); err != nil { - t.Fatal(err) - } - want.Unknown = 1 - case "offline": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET last_seen_at=clock_timestamp()-interval '46 seconds',health='{"diagnostic":"provider_unavailable"}' WHERE id=$1`, node.NodeID) - want.Unknown = 1 - case "update_required": - changeNodeTarget(t, w, view, input) - want.UpdateRequired = 1 - } - assertSandboxSnapshotEquivalent(t, s.pool) - got, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || got.Rollout.Nodes == nil || *got.Rollout.Nodes != want || got.Rollout.State != wantState { - t.Fatal("rollout classification changed", got.Rollout, err) - } - }) - } -} diff --git a/services/agents-api/internal/store/sandbox_reset.go b/services/agents-api/internal/store/sandbox_reset.go deleted file mode 100644 index 9fcfe890c..000000000 --- a/services/agents-api/internal/store/sandbox_reset.go +++ /dev/null @@ -1,270 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "math" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -var ErrSandboxResetAdmission = errors.New("hosted admission is paused for a sandbox reset") -var ErrSandboxResetInProgress = errors.New("a sandbox reset is in progress") -var ErrSandboxNotConfigured = errors.New("the sandbox deployment is not configured") - -type SandboxGenerationStaleError struct{ CurrentGeneration uint64 } - -func (e *SandboxGenerationStaleError) Error() string { - return "the sandbox deployment generation changed" -} -func (e *SandboxGenerationStaleError) Unwrap() error { return ErrSandboxDeploymentConflict } - -type SandboxResetRequiredError struct{ CurrentProvider, RequestedProvider string } - -func (e *SandboxResetRequiredError) Error() string { - return "reset the sandbox deployment before changing its backend" -} -func (e *SandboxResetRequiredError) Unwrap() error { return ErrSandboxDeploymentConflict } - -type SandboxInUseError struct{ Resources SandboxDeploymentResources } - -func (e *SandboxInUseError) Error() string { - return "hosted sandbox resources still belong to this deployment" -} -func (e *SandboxInUseError) Unwrap() error { return ErrSandboxDeploymentConflict } - -type SandboxResetRequest struct { - ExpectedGeneration uint64 `json:"expected_generation" binding:"required" minimum:"0"` - Clear string `json:"clear" binding:"required" enums:"auto,force"` - DeadlineSeconds *int32 `json:"deadline_seconds,omitempty" minimum:"300" maximum:"86400"` -} - -func checkSandboxGeneration(d sqlc.RuntimeDeployment, installation string, generation uint64) error { - if uint64(d.Generation) != generation { - return &SandboxGenerationStaleError{uint64(d.Generation)} - } - if !d.WebManaged || runtimeUUID(d.InstallationID) != installation { - return ErrSandboxDeploymentConflict - } - return nil -} - -func (s *Store) resetTransaction(ctx context.Context, apply func(context.Context, *sqlc.Queries, sqlc.RuntimeDeployment) error) error { - if s.executionLease == nil { - return ErrInvalidInput - } - ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) - defer cancel() - return s.executionLease.transaction(ctx, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - d, err := q.LockRuntimeDeployment(ctx) - if err != nil { - return err - } - return apply(ctx, q, d) - }) -} - -func (s *Store) StartSandboxReset(ctx context.Context, installation string, input SandboxResetRequest) (RuntimeDeploymentView, error) { - if input.Clear != "auto" && input.Clear != "force" { - return RuntimeDeploymentView{}, ErrInvalidInput - } - deadline := int32(3600) - if input.DeadlineSeconds != nil { - if input.Clear != "auto" || *input.DeadlineSeconds < 300 || *input.DeadlineSeconds > 86400 { - return RuntimeDeploymentView{}, ErrInvalidInput - } - deadline = *input.DeadlineSeconds - } - var result RuntimeDeploymentView - err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if err := checkSandboxGeneration(d, installation, input.ExpectedGeneration); err != nil { - return err - } - if d.ProviderKind == "" { - return ErrSandboxNotConfigured - } - if d.ResetClear.Valid { - if d.ResetClear.String != input.Clear { - if input.Clear != "force" { - return ErrSandboxResetInProgress - } - if err := q.ForceSandboxReset(ctx); err != nil { - return err - } - if err := recordDeploymentMutation(ctx, q, "reset_force", "sandbox_deployment", installation); err != nil { - return err - } - } - } else { - source, ok := adminaudit.FromContext(ctx) - if !ok { - return ErrInvalidInput - } - // The audit insert validates the provenance before this transaction - // can commit. Persist only the same non-secret typed source. - audit, err := json.Marshal(source) - if err != nil { - return err - } - if err := q.StartSandboxReset(ctx, sqlc.StartSandboxResetParams{Clear: pgtype.Text{String: input.Clear, Valid: true}, DeadlineSeconds: deadline, Audit: audit}); err != nil { - return err - } - if err := recordDeploymentMutation(ctx, q, "reset_start", "sandbox_deployment", installation); err != nil { - return err - } - } - var err error - result, err = s.deploymentView(ctx, q) - return err - }) - return result, err -} - -func (s *Store) CancelSandboxReset(ctx context.Context, installation string, generation uint64) (RuntimeDeploymentView, error) { - var result RuntimeDeploymentView - err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if err := checkSandboxGeneration(d, installation, generation); err != nil { - return err - } - if d.ResetClear.Valid { - if err := q.CancelSandboxReset(ctx); err != nil { - return err - } - if err := recordDeploymentMutation(ctx, q, "reset_cancel", "sandbox_deployment", installation); err != nil { - return err - } - } - var err error - result, err = s.deploymentView(ctx, q) - return err - }) - return result, err -} - -// AdvanceSandboxResetDeadline makes force escalation durable before selecting -// work. A restart cannot extend an administrator's original absolute deadline. -func (s *Store) AdvanceSandboxResetDeadline(ctx context.Context) error { - return s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if d.ResetClear.String != "auto" || !d.ResetDeadlineAt.Valid || time.Now().Before(d.ResetDeadlineAt.Time) { - return nil - } - source, err := sandboxResetAudit(d) - if err != nil { - return err - } - if err := q.ForceSandboxReset(ctx); err != nil { - return err - } - return recordDeploymentMutation(adminaudit.WithSource(ctx, source), q, "reset_deadline", "sandbox_deployment", runtimeUUID(d.InstallationID)) - }) -} - -func sandboxResetAudit(d sqlc.RuntimeDeployment) (adminaudit.Source, error) { - var source adminaudit.Source - if !d.ResetClear.Valid || json.Unmarshal(d.ResetAudit, &source) != nil { - return source, ErrInvalidInput - } - return source, nil -} - -// CompleteSandboxReset must run after the manager has drained. It does not take -// Session locks: archive and admission always lock Session before deployment. -func (s *Store) CompleteSandboxReset(ctx context.Context, installation string, generation uint64, requestedAt time.Time) (RuntimeDeploymentView, error) { - var result RuntimeDeploymentView - err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { - if err := checkSandboxGeneration(d, installation, generation); err != nil { - return err - } - if !d.ResetClear.Valid || !d.ResetRequestedAt.Time.Equal(requestedAt) { - return ErrSandboxDeploymentConflict - } - if d.Generation == math.MaxInt64 || d.OwnerEpoch == math.MaxInt64 { - return ErrSandboxDeploymentConflict - } - resources, err := q.CountRuntimeDeploymentResources(ctx) - if err != nil { - return err - } - if resources.Allocations != 0 || resources.Pending != 0 { - return &SandboxInUseError{SandboxDeploymentResources{Allocations: resources.Allocations, Pending: resources.Pending}} - } - source, err := sandboxResetAudit(d) - if err != nil { - return err - } - if err := q.CompleteSandboxReset(ctx); err != nil { - return err - } - if err := q.RetireSandboxNodes(ctx); err != nil { - return err - } - if err := q.RetireSandboxEnrollments(ctx); err != nil { - return err - } - if err := q.ClearSandboxGenerations(ctx); err != nil { - return err - } - if err := recordDeploymentMutation(adminaudit.WithSource(ctx, source), q, "reset_complete", "sandbox_deployment", installation); err != nil { - return err - } - result, err = s.deploymentView(ctx, q) - return err - }) - return result, err -} - -// SandboxResetView contains only durable state and a single-snapshot resource partition. -type SandboxResetView struct { - Clear string `json:"clear"` - RequestedAt time.Time `json:"requested_at"` - DeadlineAt *time.Time `json:"deadline_at" extensions:"x-nullable"` - ForcedAt *time.Time `json:"forced_at" extensions:"x-nullable"` - Remaining SandboxResetRemaining `json:"remaining"` -} -type SandboxResetRemaining struct { - Busy int64 `json:"busy"` - Idle int64 `json:"idle"` - Cleanup int64 `json:"cleanup"` - OnOfflineNodes int64 `json:"on_offline_nodes"` - OfflineNodes []SandboxResetOfflineNode `json:"offline_nodes"` -} -type SandboxResetOfflineNode struct { - NodeID string `json:"node_id"` - Name string `json:"name"` - Resources int64 `json:"resources"` -} - -func resetTimestamp(value pgtype.Timestamptz) *time.Time { - if !value.Valid { - return nil - } - return &value.Time -} - -type SandboxResetSession struct{ SessionID, TenantID string } - -func (s *Store) ListSandboxResetSessions(ctx context.Context, after string, force bool) ([]SandboxResetSession, error) { - cursor := pgtype.UUID{Valid: true} - if after != "" { - var err error - cursor, err = parseID(after) - if err != nil { - return nil, err - } - } - rows, err := s.queries.ListSandboxResetSessions(ctx, sqlc.ListSandboxResetSessionsParams{AfterID: cursor, Force: force}) - if err != nil { - return nil, err - } - result := make([]SandboxResetSession, 0, len(rows)) - for _, row := range rows { - result = append(result, SandboxResetSession{SessionID: runtimeUUID(row.ID), TenantID: runtimeUUID(row.TenantID)}) - } - return result, nil -} diff --git a/services/agents-api/internal/store/sandbox_reset_test.go b/services/agents-api/internal/store/sandbox_reset_test.go deleted file mode 100644 index 83e903403..000000000 --- a/services/agents-api/internal/store/sandbox_reset_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "reflect" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/google/uuid" -) - -func SandboxResetTestContext(ctx context.Context) context.Context { - return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "reset-fixture", ActorLabel: "operator", RequestID: "reset-request", TraceID: "reset-trace"}) -} -func resetAndSelect(t *testing.T, w *Store, installation string, generation uint64, input SandboxDeploymentSetupRequest) (RuntimeDeploymentView, error) { - t.Helper() - ctx := SandboxResetTestContext(t.Context()) - reset, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{Clear: "auto", ExpectedGeneration: generation}) - if err != nil { - return RuntimeDeploymentView{}, err - } - empty, err := w.CompleteSandboxReset(ctx, installation, generation, reset.Reset.RequestedAt) - if err != nil { - return RuntimeDeploymentView{}, err - } - input.ExpectedGeneration = empty.Generation - return w.InitializeSandboxDeployment(ctx, installation, input) -} - -func assertResetPartition(t *testing.T, view RuntimeDeploymentView) { - t.Helper() - if view.Reset == nil { - t.Fatal("missing reset") - } - remaining := view.Reset.Remaining - if remaining.Busy+remaining.Idle+remaining.Cleanup != view.Resources.Allocations+view.Resources.Pending { - t.Fatalf("inconsistent reset partition: %+v", view) - } - var offline int64 - for _, node := range remaining.OfflineNodes { - offline += node.Resources - } - if offline != remaining.OnOfflineNodes || offline > view.Resources.Allocations+view.Resources.Pending { - t.Fatalf("inconsistent offline subset: %+v", remaining) - } -} - -func TestSandboxResetAutoUsesStartedWorkAndLockedRecheck(t *testing.T) { - for _, kind := range []string{"idle", "queued", "in_progress", "waiting", "subagent_queued", "subagent_in_progress", "subagent_waiting", "file_write", "suspended", "pending"} { - t.Run(kind, func(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - var owner RuntimeAllocation - if kind != "pending" { - owner = archiveAllocation(t, w, tenant, session, installation) - } - busy := kind == "in_progress" || kind == "waiting" || kind == "subagent_in_progress" || kind == "subagent_waiting" || kind == "file_write" - switch kind { - case "queued", "in_progress", "waiting": - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,$3)`, uuid.NewString(), session.ID, kind) - case "subagent_queued", "subagent_in_progress", "subagent_waiting": - parent, child := uuid.NewString(), uuid.NewString() - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp())`, parent, session.ID) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, session.ID, parent) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, session.ID, owner.DeviceID, parent) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), session.ID, child, strings.TrimPrefix(kind, "subagent_")) - case "file_write": - runtimeSuspensionSQL(t, s.pool, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), session.Environment.ID, owner.DeviceID, strings.Repeat("a", 64)) - case "suspended": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_allocations SET compute_phase='suspended', compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, owner.ID) - } - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - assertResetPartition(t, reset) - assertSandboxSnapshotEquivalent(t, s.pool) - if (reset.Reset.Remaining.Busy == 1) != busy { - t.Fatalf("wrong busy classification: %+v", reset.Reset.Remaining) - } - page, err := w.ListSandboxResetSessions(t.Context(), "", false) - if err != nil || (len(page) == 0) != busy { - t.Fatal("auto eligibility", page, err) - } - _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt) - if busy { - if !errors.Is(err, ErrSandboxResetSessionBusy) { - t.Fatal("auto cut active work", err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err != nil { - t.Fatal(err) - } - _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt) - } - if err != nil { - t.Fatal("archive", err) - } - archived, err := s.GetSession(t.Context(), tenant, session.ID) - if err != nil || archived.Environment.Status != "expired" { - t.Fatal("archive not durable", archived, err) - } - var auditTenant, auditProject, credential, request string - if err := s.pool.QueryRow(t.Context(), `SELECT tenant_id::text,project_id::text,admin_credential_id,request_id FROM admin_audit_log WHERE action='archive' AND resource_id=$1`, session.ID).Scan(&auditTenant, &auditProject, &credential, &request); err != nil || auditTenant != tenant || auditProject != tenant || credential != "reset-fixture" || request != "reset-request" { - t.Fatal("background provenance was not reconstructed", err) - } - }) - } -} - -func TestSandboxResetCancellationABADeadlineAndGeneration(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - ctx := SandboxResetTestContext(t.Context()) - first, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - if first.Reset.DeadlineAt == nil || first.Reset.DeadlineAt.Sub(first.Reset.RequestedAt) < 3599*time.Second { - t.Fatal("default deadline", first) - } - shorter := int32(300) - replay, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto", DeadlineSeconds: &shorter}) - if err != nil || !replay.Reset.RequestedAt.Equal(first.Reset.RequestedAt) || !replay.Reset.DeadlineAt.Equal(*first.Reset.DeadlineAt) { - t.Fatal("retry moved durable deadline", replay, err) - } - if _, err = w.CancelSandboxReset(ctx, installation, 1); err != nil { - t.Fatal(err) - } - second, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - if _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, first.Reset.RequestedAt); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("cancelled reset archived successor work", err) - } - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_deadline_at=clock_timestamp()-interval '1 second'`) - if err := w.AdvanceSandboxResetDeadline(t.Context()); err != nil { - t.Fatal(err) - } - forced, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || forced.Reset.Clear != "force" || forced.Reset.ForcedAt == nil || !forced.Reset.RequestedAt.Equal(second.Reset.RequestedAt) { - t.Fatal("deadline not durable", forced, err) - } - if _, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}); !errors.Is(err, ErrSandboxResetInProgress) { - t.Fatal("force downgraded", err) - } - if _, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 0, Clear: "force"}); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("stale reset precedence", err) - } - if _, err := w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, second.Reset.RequestedAt); err != nil { - t.Fatal(err) - } - if _, err := w.CompleteSandboxReset(ctx, installation, 1, first.Reset.RequestedAt); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("cancelled reset finalized successor", err) - } - empty, err := w.CompleteSandboxReset(ctx, installation, 1, second.Reset.RequestedAt) - if err != nil || empty.Provider != "" || empty.Generation != 2 || empty.Reset != nil || empty.InstallationID != installation || empty.E2B != nil || empty.Specification != nil { - t.Fatal("reset commit", empty, err) - } - if _, err := w.CancelSandboxReset(ctx, installation, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { - t.Fatal("stale cancel", err) - } - if _, err := w.CancelSandboxReset(ctx, installation, 2); err != nil { - t.Fatal("cancel without reset", err) - } -} - -func TestSandboxResetAutoRechecksTurnStartedAfterListing(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - archiveAllocation(t, w, tenant, session, installation) - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - page, err := w.ListSandboxResetSessions(t.Context(), "", false) - if err != nil || len(page) != 1 { - t.Fatal(page, err) - } - // Existing live input remains admitted; Turn transition takes the same - // Session lock that the later conditional archive must reacquire. - turn := submitMessage(t, s, tenant, session.ID, "after-list") - transition(t, w, tenant, session.ID, turn.TurnID, TurnQueued, TurnInProgress) - if _, err := w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt); !errors.Is(err, ErrSandboxResetSessionBusy) { - t.Fatal("listed idle candidate cut a new Turn", err) - } - before := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "turns", "runtime_placements") - if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())); !errors.Is(err, ErrSandboxResetAdmission) { - t.Fatal("new hosted admission during reset", err) - } - after := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "turns", "runtime_placements") - if !reflect.DeepEqual(before, after) { - t.Fatal("rejected admission left provisional rows") - } - if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), installation, 1); err != nil { - t.Fatal(err) - } - if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())); err != nil { - t.Fatal("cancel did not restore admission", err) - } -} - -func TestSandboxResetAuditFailureRollsBackPauseAndCompletion(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - rejectAdminAuditInsert(t, s) - rejected := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "reset-fixture", ActorLabel: "operator", RequestID: rejectedAdminRequest, TraceID: "reset-trace"}) - if _, err := w.StartSandboxReset(rejected, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err == nil { - t.Fatal("reset committed without audit") - } - view, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || view.Reset != nil || view.Generation != 1 { - t.Fatal("failed audit left reset state", view, err) - } - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}) - if err != nil { - t.Fatal(err) - } - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_audit=jsonb_set(reset_audit,'{RequestID}',to_jsonb($1::text))`, rejectedAdminRequest) - if _, err := w.CompleteSandboxReset(t.Context(), installation, 1, reset.Reset.RequestedAt); err == nil { - t.Fatal("completion committed without audit") - } - view, err = s.GetRuntimeDeployment(t.Context()) - if err != nil || view.Reset == nil || view.Provider != "e2b" || view.Generation != 1 || view.OwnerEpoch != reset.OwnerEpoch { - t.Fatal("failed completion destroyed committed provider", view, err) - } -} - -func TestSandboxResetSnapshotCountsOfflineOwnershipOnce(t *testing.T) { - s, w, deployment := managerFixture(t, 10, 10) - // Reuse the real placement fixture, then adopt its selection as Web-managed. - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET web_managed=true,local_node_id=NULL`) - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET deployment_generation=1,specification_digest=$1`, SandboxDeploymentTestSpec("docker").Digest("docker")) - _, pending := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - tenant, suspended := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - allocation := archiveAllocation(t, w, tenant, suspended, deployment.InstallationID) - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_allocations SET compute_phase='suspended',compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, allocation.ID) - tenant, deleted := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - archiveAllocation(t, w, tenant, deleted, deployment.InstallationID) - if err := s.DeleteSession(t.Context(), tenant, deleted.ID); err != nil { - t.Fatal(err) - } - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), deployment.InstallationID, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - assertResetPartition(t, reset) - if reset.Resources != (SandboxDeploymentResources{Allocations: 2, Pending: 1}) || reset.Reset.Remaining.Idle != 2 || reset.Reset.Remaining.Cleanup != 1 { - t.Fatal("duplicated placement or missing deleted receipt", reset) - } - for _, state := range []string{"preparing", "stale", "epoch", "disconnected"} { - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connected_epoch=(SELECT owner_epoch FROM runtime_deployment)`) - onlineManagerNode(t, s, deployment.LocalNodeID) - switch state { - case "preparing": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET provider_ready=false`) - case "stale": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET last_seen_at=clock_timestamp()-interval '46 seconds'`) - case "epoch": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connected_epoch=connected_epoch+1`) - case "disconnected": - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connection_id=NULL`) - } - view, err := s.GetRuntimeDeployment(t.Context()) - if err != nil { - t.Fatal(err) - } - assertResetPartition(t, view) - assertSandboxSnapshotEquivalent(t, s.pool) - want := int64(3) - if state == "preparing" { - want = 0 - } - if view.Reset.Remaining.OnOfflineNodes != want { - t.Fatalf("%s presence: %+v", state, view.Reset.Remaining) - } - if want > 0 && (len(view.Reset.Remaining.OfflineNodes) != 1 || view.Reset.Remaining.OfflineNodes[0].NodeID != deployment.LocalNodeID || view.Reset.Remaining.OfflineNodes[0].Resources != 3) { - t.Fatal("offline ownership projection", view.Reset.Remaining) - } - } - if _, err := w.CompleteSandboxReset(t.Context(), deployment.InstallationID, 1, reset.Reset.RequestedAt); err == nil { - t.Fatal("offline resources were treated as cleaned") - } - if err := s.RemoveRuntimeNode(t.Context(), deployment.LocalNodeID); !errors.Is(err, ErrRuntimeNodeInUse) { - t.Fatal("removed node with reset resources", err) - } - if row, err := s.GetEnvironment(t.Context(), pending.TenantID, pending.Environment.ID); err == nil && row.Status == "expired" { - t.Fatal("projection mutated pending resource") - } -} - -func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - // Use deterministic ordered UUIDs so more than one page of busy rows precedes - // idle work. Listing must not repeatedly return the busy prefix. - for i := 1; i <= 35; i++ { - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - id := fmt.Sprintf("00000000-0000-4000-8000-%012d", i) - // IDs are immutable API identities, so seed this bounded scheduling fixture - // directly instead of modifying an existing Session primary key. - runtimeSuspensionSQL(t, s.pool, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) SELECT $1::uuid,tenant_id,engine,$1::text,$1::text,configuration FROM sessions WHERE id=$2`, id, session.ID) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO environments(id,session_id) VALUES($1,$2)`, uuid.NewString(), id) - if i <= 34 { - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'in_progress')`, uuid.NewString(), id) - } - if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { - t.Fatal(err) - } - } - selfTenant, self := managedArchiveSession(t, s, environmentInput(uuid.NewString(), "self_hosted", "/workspace")) - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - page, err := w.ListSandboxResetSessions(t.Context(), "", false) - if err != nil || len(page) != 1 || page[0].SessionID != "00000000-0000-4000-8000-000000000035" { - t.Fatal("busy prefix starved idle work", page, err) - } - if _, err := w.ArchiveSandboxResetSession(t.Context(), page[0].TenantID, page[0].SessionID, 1, reset.Reset.RequestedAt); err != nil { - t.Fatal(err) - } - if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err != nil { - t.Fatal(err) - } - first, err := w.ListSandboxResetSessions(t.Context(), "", true) - if err != nil || len(first) != 32 { - t.Fatal(first, err) - } - second, err := w.ListSandboxResetSessions(t.Context(), first[31].SessionID, true) - if err != nil || len(second) != 2 { - t.Fatal(second, err) - } - if _, err := w.ArchiveSandboxResetSession(t.Context(), selfTenant, self.ID, 1, reset.Reset.RequestedAt); !errors.Is(err, ErrInvalidInput) { - t.Fatal("self-hosted reset archive", err) - } - view, err := s.GetSession(t.Context(), selfTenant, self.ID) - if err != nil || view.Environment.Status == "expired" { - t.Fatal("reset changed self-hosted Session", view, err) - } -} - -func TestSandboxResetOwnerRestartRetainsDeadlineAndProvenance(t *testing.T) { - s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) - reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) - if err != nil { - t.Fatal(err) - } - // Model an abrupt owner loss and wait for PostgreSQL to terminate that backend, - // rather than assuming local TCP cleanup acknowledges advisory-lock release. - var stopped bool - if err := s.pool.QueryRow(t.Context(), `SELECT pg_terminate_backend($1,1000)`, w.executionLease.conn.Conn().PgConn().PID()).Scan(&stopped); err != nil || !stopped { - t.Fatal(stopped, err) - } - successor := executionLease(t, s).Store() - current, err := s.GetRuntimeDeployment(t.Context()) - if err != nil || !current.Reset.RequestedAt.Equal(reset.Reset.RequestedAt) || !current.Reset.DeadlineAt.Equal(*reset.Reset.DeadlineAt) { - t.Fatal("restart moved reset deadline", current, err) - } - if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), installation, 1); err == nil { - t.Fatal("detached writer cancelled successor reset") - } - runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_deadline_at=clock_timestamp()-interval '1 second'`) - if err := successor.AdvanceSandboxResetDeadline(t.Context()); err != nil { - t.Fatal(err) - } - if _, err := successor.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt); err != nil { - t.Fatal(err) - } - empty, err := successor.CompleteSandboxReset(t.Context(), installation, 1, reset.Reset.RequestedAt) - if err != nil || empty.Generation != 2 || empty.Provider != "" { - t.Fatal(empty, err) - } - var actions []string - if err := s.pool.QueryRow(t.Context(), `SELECT array_agg(action ORDER BY action) FROM admin_audit_log WHERE action IN ('reset_start','reset_deadline','reset_complete') AND request_id='reset-request' AND admin_credential_id='reset-fixture'`).Scan(&actions); err != nil || len(actions) != 3 { - t.Fatal("restart lost requester audit", actions, err) - } -} diff --git a/services/agents-api/internal/store/session_artifacts.go b/services/agents-api/internal/store/session_artifacts.go deleted file mode 100644 index 648eb7c3b..000000000 --- a/services/agents-api/internal/store/session_artifacts.go +++ /dev/null @@ -1,170 +0,0 @@ -package store - -import ( - "context" - "errors" - "io" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type SessionArtifact struct { - ID string - SessionID string - TurnID string - EnvironmentID string - Path string - SizeBytes int64 - CreatedAt time.Time -} - -type ArtifactPage struct { - Artifacts []SessionArtifact - NextCursor string -} - -func (s *Store) GetSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) (SessionArtifact, error) { - lookup, err := artifactLookup(tenantID, sessionID, artifactID) - if err != nil { - return SessionArtifact{}, err - } - row, err := s.queries.GetSessionArtifact(ctx, lookup) - if errors.Is(err, pgx.ErrNoRows) { - return SessionArtifact{}, ErrNotFound - } - return artifactFromRow(row), err -} - -func (s *Store) ListSessionArtifacts(ctx context.Context, tenantID, sessionID, environmentID, cursor string, limit int, ascending bool) (ArtifactPage, error) { - if limit < 1 || limit > 100 { - return ArtifactPage{}, ErrInvalidInput - } - if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { - return ArtifactPage{}, err - } - tenant, _ := parseID(tenantID) - session, _ := parseID(sessionID) - params := sqlc.ListSessionArtifactsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} - if environmentID != "" { - // A malformed filter matches nothing, like another Environment's ID (HE-56). - params.EnvironmentID = parsePathID(environmentID) - } - if cursor != "" { - // Any cursor that is not an Artifact of this Session, including a - // malformed one, is an invalid cursor rather than a missing resource. - after, err := s.GetSessionArtifact(ctx, tenantID, sessionID, cursor) - if errors.Is(err, ErrNotFound) { - // The Session lookup above is a separate statement: a Session deleted - // since then stays not found. Deleted Sessions never reappear, so an - // existing one here also existed when the cursor was read. - if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { - return ArtifactPage{}, err - } - return ArtifactPage{}, errArtifactCursor - } - if err != nil { - return ArtifactPage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - params.AfterID, _ = parseID(after.ID) - } - rows, err := s.queries.ListSessionArtifacts(ctx, params) - if err != nil { - return ArtifactPage{}, err - } - page := ArtifactPage{Artifacts: make([]SessionArtifact, 0, min(limit, len(rows)))} - if len(rows) > limit { - page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() - rows = rows[:limit] - } - for _, row := range rows { - page.Artifacts = append(page.Artifacts, artifactFromRow(row)) - } - return page, nil -} - -// ReadSessionArtifact keeps an admitted snapshot available across concurrent deletion. -func (s *Store) ReadSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string, consume func(SessionArtifact, io.Reader) error) error { - lookup, err := artifactLookup(tenantID, sessionID, artifactID) - if err != nil { - return err - } - if consume == nil { - return ErrInvalidInput - } - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) - if err != nil { - return err - } - defer tx.Rollback(context.Background()) - row, err := s.queries.WithTx(tx).GetSessionArtifact(ctx, lookup) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - objects := tx.LargeObjects() - body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) - if err != nil { - return err - } - if err := consume(artifactFromRow(row), body); err != nil { - return err - } - if err := body.Close(); err != nil { - return err - } - return tx.Commit(ctx) -} - -func (s *Store) DeleteSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) error { - lookup, err := artifactLookup(tenantID, sessionID, artifactID) - if err != nil { - return err - } - tx, err := s.pool.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(context.Background()) - q := s.queries.WithTx(tx) - // Use the same lock order as whole-Session deletion and Turn publication. - locked, err := q.LockSession(ctx, sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}) - if errors.Is(err, pgx.ErrNoRows) || err == nil && locked.DeletedAt.Valid { - return ErrNotFound - } - if err != nil { - return err - } - oid, err := q.DeleteSessionArtifact(ctx, sqlc.DeleteSessionArtifactParams(lookup)) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - objects := tx.LargeObjects() - if err := objects.Unlink(ctx, oid.Uint32); err != nil { - return err - } - if err := recordWriteAudit(ctx, q, tenantID, "delete", "artifact", uuid.UUID(lookup.ID.Bytes).String(), uuid.UUID(lookup.SessionID.Bytes).String()); err != nil { - return err - } - return tx.Commit(ctx) -} - -func artifactLookup(tenantID, sessionID, artifactID string) (sqlc.GetSessionArtifactParams, error) { - ids, err := publicTurnLookup(tenantID, sessionID, artifactID) - return sqlc.GetSessionArtifactParams{TenantID: ids.TenantID, SessionID: ids.SessionID, ID: ids.ID}, err -} - -func artifactFromRow(row sqlc.SessionArtifact) SessionArtifact { - return SessionArtifact{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), - TurnID: uuid.UUID(row.TurnID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), - Path: row.Path, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} -} diff --git a/services/agents-api/internal/store/session_artifacts_test.go b/services/agents-api/internal/store/session_artifacts_test.go deleted file mode 100644 index a22a6675a..000000000 --- a/services/agents-api/internal/store/session_artifacts_test.go +++ /dev/null @@ -1,580 +0,0 @@ -package store - -import ( - "archive/tar" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "reflect" - "sort" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" -) - -func artifactArchive(t *testing.T, files map[string][]byte) []byte { - t.Helper() - var data bytes.Buffer - w := tar.NewWriter(&data) - for name, body := range files { - if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { - t.Fatal(err) - } - if _, err := w.Write(body); err != nil { - t.Fatal(err) - } - } - if err := w.Close(); err != nil { - t.Fatal(err) - } - return data.Bytes() -} - -func artifactTurn(t *testing.T, s *Store, kind string) (tenant, session, environment, turn string) { - t.Helper() - tenant = uuid.NewString() - created, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact", kind, "/workspace")) - if err != nil { - t.Fatal(err) - } - env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) - if err != nil { - t.Fatal(err) - } - input := submitMessage(t, s, tenant, created.ID, "artifact-turn") - transition(t, s, tenant, created.ID, input.TurnID, TurnQueued, TurnInProgress) - return tenant, created.ID, env.ID, input.TurnID -} - -func TestSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T) { - for _, kind := range []string{"openai_hosted", "self_hosted"} { - t.Run(kind, func(t *testing.T) { testSessionArtifactsPublishVersionScopeAndLifetime(t, kind) }) - } -} - -func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind string) { - s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, kind) - before := sourceObjectCount(t, pool) - data := bytes.Repeat([]byte("immutable\x00"), 100000) - archive := artifactArchive(t, map[string][]byte{"outputs/a.bin": data, "outputs/nested/empty": {}}) - if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(archive)); err != nil { - t.Fatal(err) - } - page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) - if err != nil || len(page.Artifacts) != 0 { - t.Fatalf("private capture visible: %+v %v", page, err) - } - completed := transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) - page, err = s.ListSessionArtifacts(t.Context(), tenant, session, environment, "", 100, true) - if err != nil || len(page.Artifacts) != 2 { - t.Fatalf("published capture: %+v %v", page, err) - } - for _, a := range page.Artifacts { - if a.SessionID != session || a.TurnID != turn || a.EnvironmentID != environment || !a.CreatedAt.Equal(completed.CompletedAt) { - t.Fatalf("publication metadata: %+v", a) - } - foreign := uuid.NewString() - if _, err := s.GetSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign metadata: %v", err) - } - if _, err := s.GetSessionArtifact(t.Context(), tenant, uuid.NewString(), a.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("wrong session metadata: %v", err) - } - if err := s.DeleteSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign delete: %v", err) - } - if err := s.ReadSessionArtifact(t.Context(), foreign, session, a.ID, func(SessionArtifact, io.Reader) error { - t.Error("foreign read reached content") - return nil - }); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign read: %v", err) - } - } - if _, err := s.ListSessionArtifacts(t.Context(), uuid.NewString(), session, "", "", 100, false); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign list: %v", err) - } - if empty, err := s.ListSessionArtifacts(t.Context(), tenant, session, uuid.NewString(), "", 100, false); err != nil || len(empty.Artifacts) != 0 { - t.Fatalf("environment filter: %+v %v", empty, err) - } - // A later completed Turn publishes another immutable version of the same path. - next := submitMessage(t, s, tenant, session, "version-two") - transition(t, s, tenant, session, next.TurnID, TurnQueued, TurnInProgress) - if err := s.StageTurnArtifacts(t.Context(), tenant, session, next.TurnID, environment, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/a.bin": []byte("new")}))); err != nil { - t.Fatal(err) - } - transition(t, s, tenant, session, next.TurnID, TurnInProgress, TurnCompleted) - all, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) - if err != nil || len(all.Artifacts) != 3 { - t.Fatal(all, err) - } - for _, asc := range []bool{true, false} { - var got []SessionArtifact - cursor := "" - for { - part, err := s.ListSessionArtifacts(t.Context(), tenant, session, environment, cursor, 1, asc) - if err != nil { - t.Fatal(err) - } - got = append(got, part.Artifacts...) - if part.NextCursor == "" { - break - } - if len(got) > 3 { - t.Fatal("pagination repeated artifacts") - } - cursor = part.NextCursor - } - want := append([]SessionArtifact(nil), all.Artifacts...) - if !asc { - want[0], want[2] = want[2], want[0] - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("ordered pages differ: %+v %+v", got, want) - } - } - // Expiration is a controlled fixture; stored reads must not touch Runtime. - if _, err := pool.Exec(t.Context(), "UPDATE environments SET status='expired' WHERE id=$1", environment); err != nil { - t.Fatal(err) - } - for _, a := range page.Artifacts { - if err := New(pool).ReadSessionArtifact(t.Context(), tenant, session, a.ID, func(meta SessionArtifact, r io.Reader) error { - if err := s.DeleteSessionArtifact(t.Context(), tenant, session, a.ID); err != nil { - return err - } - body, err := io.ReadAll(r) - want := data - if a.Path == "/workspace/outputs/nested/empty" { - want = nil - } - if meta != a || !bytes.Equal(body, want) { - t.Error("expired/deleted artifact damaged admitted snapshot") - } - return err - }); err != nil { - t.Fatal(err) - } - if _, err := s.GetSessionArtifact(t.Context(), tenant, session, a.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted metadata retained: %v", err) - } - } - if err := s.DeleteSession(t.Context(), tenant, session); err != nil { - t.Fatal(err) - } - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("objects leaked: %d -> %d", before, count) - } -} - -type artifactReadError struct{} - -func (artifactReadError) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } - -func TestSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T) { - for _, kind := range []string{"openai_hosted", "self_hosted"} { - t.Run(kind, func(t *testing.T) { testSessionArtifactsRejectIncompleteAndUnownedCapture(t, kind) }) - } -} - -func testSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T, kind string) { - s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, kind) - before := sourceObjectCount(t, pool) - valid := artifactArchive(t, map[string][]byte{"outputs/a": []byte("data")}) - for name, body := range map[string]io.Reader{ - "transport-failure-after-valid-tar": io.MultiReader(bytes.NewReader(valid), artifactReadError{}), - "truncated-body": bytes.NewReader(valid[:513]), - "trailing-data": io.MultiReader(bytes.NewReader(valid), bytes.NewReader([]byte("not archive padding"))), - "traversal": bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/../secret": []byte("no")})), - "private-root": bytes.NewReader(artifactArchive(t, map[string][]byte{"secrets/key": []byte("no")})), - } { - t.Run(name, func(t *testing.T) { - if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, body); err == nil { - t.Fatal("invalid capture accepted") - } - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("rollback leaked objects: %d -> %d", before, count) - } - }) - } - for _, ids := range [][4]string{{uuid.NewString(), session, turn, environment}, {tenant, session, turn, uuid.NewString()}} { - if err := s.StageTurnArtifacts(t.Context(), ids[0], ids[1], ids[2], ids[3], artifactReadError{}); !errors.Is(err, ErrNotFound) { - t.Fatalf("unauthorized capture reached reader: %v", err) - } - } -} - -func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { - for _, status := range []string{TurnFailed, TurnCancelled} { - t.Run(status, func(t *testing.T) { - s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") - before := sourceObjectCount(t, pool) - body := artifactArchive(t, map[string][]byte{"outputs/a": []byte("private")}) - if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); err != nil { - t.Fatal(err) - } - transition(t, s, tenant, session, turn, TurnInProgress, status) - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("terminal capture leaked objects: %d -> %d", before, count) - } - if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); !errors.Is(err, ErrTurnConflict) { - t.Fatalf("late capture accepted: %v", err) - } - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("late capture leaked objects: %d -> %d", before, count) - } - }) - } -} - -func TestSessionArtifactTransferDoesNotBlockDeletionOrCancellation(t *testing.T) { - for _, operation := range []string{"delete", "cancel"} { - t.Run(operation, func(t *testing.T) { - s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") - before := sourceObjectCount(t, pool) - reader, writer := io.Pipe() - defer reader.Close() - defer writer.Close() - result := make(chan error, 1) - go func() { result <- s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, reader) }() - // A complete TAR arrives, but transport has not acknowledged success yet. - if _, err := writer.Write(artifactArchive(t, map[string][]byte{"outputs/a": []byte("partial")})); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - want := ErrNotFound - if operation == "delete" { - // The idle-only decision itself is not blocked by the transfer. - if err := s.DeleteSession(ctx, tenant, session); !errors.Is(err, ErrSessionNotIdle) { - t.Fatalf("transfer blocked or bypassed the deletion rule: %v", err) - } - if err := s.commitLegacyDeletion(ctx, tenant, session); err != nil { - t.Fatalf("transfer blocked deletion: %v", err) - } - } else { - if _, err := s.RequestCancel(ctx, tenant, session, "cancel-capture"); err != nil { - t.Fatalf("transfer blocked cancellation: %v", err) - } - want = ErrTurnConflict - } - writer.Close() - if err := <-result; !errors.Is(err, want) { - t.Fatalf("late publication after %s: %v", operation, err) - } - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("late capture leaked objects: %d -> %d", before, count) - } - }) - } -} - -// startArtifactTurn admits another message and starts its Turn. -func startArtifactTurn(t *testing.T, s *Store, tenant, session, key string) string { - t.Helper() - input := submitMessage(t, s, tenant, session, key) - transition(t, s, tenant, session, input.TurnID, TurnQueued, TurnInProgress) - return input.TurnID -} - -// stageArtifactOutputs privately captures one complete outputs tree for a Turn. -func stageArtifactOutputs(t *testing.T, s *Store, tenant, session, environment, turn string, files map[string]string) { - t.Helper() - archive := make(map[string][]byte, len(files)) - for name, body := range files { - archive["outputs/"+name] = []byte(body) - } - if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(artifactArchive(t, archive))); err != nil { - t.Fatal(err) - } -} - -// publishedByTurn returns the Artifacts one Turn published, keyed by outputs-relative path. -func publishedByTurn(t *testing.T, s *Store, tenant, session, turn string) map[string]SessionArtifact { - t.Helper() - page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) - if err != nil || page.NextCursor != "" { - t.Fatalf("list: %+v %v", page, err) - } - got := make(map[string]SessionArtifact) - for _, artifact := range page.Artifacts { - if artifact.TurnID == turn { - got[strings.TrimPrefix(artifact.Path, "/workspace/outputs/")] = artifact - } - } - return got -} - -func artifactBytes(t *testing.T, s *Store, tenant, session, id string) string { - t.Helper() - var body []byte - if err := s.ReadSessionArtifact(t.Context(), tenant, session, id, func(_ SessionArtifact, r io.Reader) error { - var err error - body, err = io.ReadAll(r) - return err - }); err != nil { - t.Fatal(err) - } - return string(body) -} - -func publishedPaths(published map[string]SessionArtifact) []string { - paths := make([]string, 0, len(published)) - for path := range published { - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} - -// Later Turns publish a path only when it is new, its bytes differ from the -// newest remaining Artifact for that path, or no Artifact remains for it (HE-52). -func TestSessionArtifactsRepublishOnlyNewChangedOrDeletedPaths(t *testing.T) { - s, pool := testStore(t) - tenant, session, environment, first := artifactTurn(t, s, "openai_hosted") - before := sourceObjectCount(t, pool) - turnNumber := 1 - run := func(files map[string]string, want ...string) map[string]SessionArtifact { - t.Helper() - turn := first - if turnNumber > 1 { - turn = startArtifactTurn(t, s, tenant, session, fmt.Sprintf("artifact-turn-%d", turnNumber)) - } - turnNumber++ - stageArtifactOutputs(t, s, tenant, session, environment, turn, files) - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) - published := publishedByTurn(t, s, tenant, session, turn) - sort.Strings(want) - if got := publishedPaths(published); strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("Turn %d published %v, want %v", turnNumber-1, got, want) - } - for path, artifact := range published { - if body := artifactBytes(t, s, tenant, session, artifact.ID); body != files[path] { - t.Fatalf("Turn %d %s bytes = %q, want %q", turnNumber-1, path, body, files[path]) - } - } - return published - } - unchanged := func(artifacts ...SessionArtifact) { - t.Helper() - for _, artifact := range artifacts { - if got, err := s.GetSessionArtifact(t.Context(), tenant, session, artifact.ID); err != nil || got != artifact { - t.Fatalf("existing Artifact changed: %+v -> %+v %v", artifact, got, err) - } - } - } - objects := func() { - t.Helper() - var rows int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_artifacts WHERE session_id = $1", session).Scan(&rows); err != nil { - t.Fatal(err) - } - if count := sourceObjectCount(t, pool); count != before+rows { - t.Fatalf("unpublished captures kept private objects: %d objects for %d Artifacts", count-before, rows) - } - } - - // The first Turn behaves as before: every regular output is published. - outputs := map[string]string{"a.txt": "alpha", "sub/b.txt": "bravo", "empty.txt": ""} - one := run(outputs, "a.txt", "sub/b.txt", "empty.txt") - if err := s.DeleteSessionArtifact(t.Context(), tenant, session, one["a.txt"].ID); err != nil { - t.Fatal(err) - } - // New c.txt and deleted-then-unchanged a.txt; unchanged paths keep their IDs. - outputs["c.txt"] = "charlie" - two := run(outputs, "a.txt", "c.txt") - unchanged(one["sub/b.txt"], one["empty.txt"]) - objects() - // Changed bytes publish a new version and leave the earlier one intact. - outputs["sub/b.txt"] = "bravo-v2" - three := run(outputs, "sub/b.txt") - unchanged(one["sub/b.txt"], one["empty.txt"], two["a.txt"], two["c.txt"]) - if body := artifactBytes(t, s, tenant, session, one["sub/b.txt"].ID); body != "bravo" { - t.Fatalf("earlier version changed: %q", body) - } - // Comparison uses the newest version, not any earlier one with equal bytes. - outputs["sub/b.txt"] = "bravo" - four := run(outputs, "sub/b.txt") - // Entirely unchanged outputs, and a removed workspace file, publish nothing. - delete(outputs, "c.txt") - run(outputs) - unchanged(one["sub/b.txt"], one["empty.txt"], two["a.txt"], two["c.txt"], three["sub/b.txt"], four["sub/b.txt"]) - objects() - // Deletion leaves no tombstone: the newest remaining version is the comparison base. - if err := s.DeleteSessionArtifact(t.Context(), tenant, session, four["sub/b.txt"].ID); err != nil { - t.Fatal(err) - } - outputs["sub/b.txt"] = "bravo-v2" - run(outputs) - outputs["sub/b.txt"] = "bravo" - run(outputs, "sub/b.txt") - - // A deletion committed after private capture but before completion is seen - // by the completion transaction, so the same Turn republishes the path. - turn := startArtifactTurn(t, s, tenant, session, "artifact-turn-delete-during-capture") - stageArtifactOutputs(t, s, tenant, session, environment, turn, outputs) - if err := s.DeleteSessionArtifact(t.Context(), tenant, session, two["a.txt"].ID); err != nil { - t.Fatal(err) - } - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) - if got := publishedPaths(publishedByTurn(t, s, tenant, session, turn)); !reflect.DeepEqual(got, []string{"a.txt"}) { - t.Fatalf("deletion during capture: published %v", got) - } - objects() - - // Another Session in the same tenant never compares against these Artifacts. - other, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact-other", "openai_hosted", "/workspace")) - if err != nil { - t.Fatal(err) - } - otherEnvironment, err := s.GetSessionEnvironment(t.Context(), tenant, other.ID) - if err != nil { - t.Fatal(err) - } - otherTurn := startArtifactTurn(t, s, tenant, other.ID, "artifact-other-turn") - stageArtifactOutputs(t, s, tenant, other.ID, otherEnvironment.ID, otherTurn, outputs) - transition(t, s, tenant, other.ID, otherTurn, TurnInProgress, TurnCompleted) - if got := publishedPaths(publishedByTurn(t, s, tenant, other.ID, otherTurn)); !reflect.DeepEqual(got, []string{"a.txt", "empty.txt", "sub/b.txt"}) { - t.Fatalf("other Session first Turn published %v", got) - } - for _, id := range []string{session, other.ID} { - if err := s.DeleteSession(t.Context(), tenant, id); err != nil { - t.Fatal(err) - } - } - if count := sourceObjectCount(t, pool); count != before { - t.Fatalf("objects leaked: %d -> %d", before, count) - } -} - -// Publication time can come from the Runtime's reported completion and invert -// the order of Turns; the newest version for a path still follows Turn order. -func TestSessionArtifactsNewestVersionFollowsTurnOrder(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - created, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact-order", "openai_hosted", "/workspace")) - if err != nil { - t.Fatal(err) - } - session := created.ID - env, err := s.GetSessionEnvironment(t.Context(), tenant, session) - if err != nil { - t.Fatal(err) - } - // Turn 1 reports a native completion one hour ahead, so its Artifact is - // published later than every following Turn's. - first := submitMessage(t, s, tenant, session, "artifact-order-1") - transition(t, s, tenant, session, first.TurnID, TurnQueued, TurnInProgress) - stageArtifactOutputs(t, s, tenant, session, env.ID, first.TurnID, map[string]string{"b.txt": "bravo"}) - future := time.Now().Add(time.Hour).UnixMilli() - if _, err := s.CompleteExecution(t.Context(), tenant, session, first.TurnID, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, future)), "", first.Sequence); err != nil { - t.Fatal(err) - } - one := publishedByTurn(t, s, tenant, session, first.TurnID)["b.txt"] - run := func(key, body string) map[string]SessionArtifact { - t.Helper() - turn := startArtifactTurn(t, s, tenant, session, key) - stageArtifactOutputs(t, s, tenant, session, env.ID, turn, map[string]string{"b.txt": body}) - transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) - return publishedByTurn(t, s, tenant, session, turn) - } - two := run("artifact-order-2", "bravo-v2")["b.txt"] - if two.ID == "" || !two.CreatedAt.Before(one.CreatedAt) { - t.Fatalf("fixture did not invert publication time: %+v %+v", one, two) - } - // Turn 2's version is the newest although Turn 1 was published later. - if got := run("artifact-order-3", "bravo-v2"); len(got) != 0 { - t.Fatalf("unchanged bytes of the newest Turn republished: %+v", got) - } - if got := publishedPaths(run("artifact-order-4", "bravo")); strings.Join(got, ",") != "b.txt" { - t.Fatalf("bytes of an older Turn's version were not republished: %v", got) - } -} - -// A deletion that holds the Session lock while Turn completion waits for it is -// seen by the completion transaction, which then republishes the path. -func TestSessionArtifactsCompletionWaitsForConcurrentDeletion(t *testing.T) { - s, pool := testStore(t) - tenant, session, environment, first := artifactTurn(t, s, "openai_hosted") - before := sourceObjectCount(t, pool) - stageArtifactOutputs(t, s, tenant, session, environment, first, map[string]string{"a.txt": "alpha"}) - transition(t, s, tenant, session, first, TurnInProgress, TurnCompleted) - newest := publishedByTurn(t, s, tenant, session, first)["a.txt"] - turn := startArtifactTurn(t, s, tenant, session, "artifact-concurrent-delete") - stageArtifactOutputs(t, s, tenant, session, environment, turn, map[string]string{"a.txt": "alpha"}) - - // Delete exactly as DeleteSessionArtifact does, but keep the transaction open. - tx, err := pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - lookup, err := artifactLookup(tenant, session, newest.ID) - if err != nil { - t.Fatal(err) - } - q := s.queries.WithTx(tx) - if _, err := q.LockSession(t.Context(), sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}); err != nil { - t.Fatal(err) - } - oid, err := q.DeleteSessionArtifact(t.Context(), sqlc.DeleteSessionArtifactParams(lookup)) - if err != nil { - t.Fatal(err) - } - objects := tx.LargeObjects() - if err := objects.Unlink(t.Context(), oid.Uint32); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { - _, err := s.TransitionTurn(t.Context(), tenant, session, turn, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}) - done <- err - }() - // Completion must be blocked on the Session lock before the deletion commits. - deadline := time.Now().Add(10 * time.Second) - for { - var waiting int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'").Scan(&waiting); err != nil { - t.Fatal(err) - } - if waiting > 0 { - break - } - select { - case err := <-done: - t.Fatalf("completion did not wait for the Session lock: %v", err) - default: - } - if time.Now().After(deadline) { - t.Fatal("completion never waited for the Session lock") - } - time.Sleep(10 * time.Millisecond) - } - if status, err := s.GetTurn(t.Context(), tenant, session, turn); err != nil || status.Status != TurnInProgress { - t.Fatalf("Turn settled while the deletion held the lock: %+v %v", status, err) - } - if err := tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if err := <-done; err != nil { - t.Fatal(err) - } - published := publishedByTurn(t, s, tenant, session, turn) - if got := publishedPaths(published); strings.Join(got, ",") != "a.txt" || artifactBytes(t, s, tenant, session, published["a.txt"].ID) != "alpha" { - t.Fatalf("concurrent deletion was not republished: %v", got) - } - if _, err := s.GetSessionArtifact(t.Context(), tenant, session, newest.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("deleted Artifact remains: %v", err) - } - if count := sourceObjectCount(t, pool); count != before+1 { - t.Fatalf("private objects: %d -> %d, want one published Artifact", before, count) - } -} diff --git a/services/agents-api/internal/store/session_creation_identity.go b/services/agents-api/internal/store/session_creation_identity.go deleted file mode 100644 index 7bf2642d0..000000000 --- a/services/agents-api/internal/store/session_creation_identity.go +++ /dev/null @@ -1,158 +0,0 @@ -package store - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// modelProviderKeyPurpose separates model provider key fingerprints from any -// other use of the credential key. -const modelProviderKeyPurpose = "parsar.agents-api.model-provider-api-key.v1" - -// fingerprintedProvider replaces a bundle's key with its keyed fingerprint, so -// idempotency hashes tell keys apart without ever hashing a key itself. -func (s *Store) fingerprintedProvider(provider *v1.ModelProviderInput) (*v1.ModelProviderInput, error) { - if provider == nil { - return nil, nil - } - fingerprint, err := s.credentialCipher.Fingerprint(modelProviderKeyPurpose, provider.APIKey) - if err != nil { - return nil, ErrCredentialStorageUnavailable - } - copy := *provider - copy.APIKey = "fingerprint:" + fingerprint - return ©, nil -} - -// withoutProviderKey replaces a caller intent's x_agents_core.model_provider -// with its typed value carrying a keyed fingerprint instead of the key. It fails -// closed: an intent whose extension or provider cannot be read is rejected -// rather than hashed as raw bytes that could carry a key. -func (s *Store) withoutProviderKey(raw json.RawMessage) (json.RawMessage, error) { - var request map[string]json.RawMessage - if json.Unmarshal(raw, &request) != nil || request == nil { - return nil, ErrInvalidInput - } - extensionRaw, present := request["x_agents_core"] - if !present || jsonNull(extensionRaw) { - return raw, nil - } - var extension map[string]json.RawMessage - if json.Unmarshal(extensionRaw, &extension) != nil || extension == nil { - return nil, ErrInvalidInput - } - providerRaw, present := extension["model_provider"] - if !present || jsonNull(providerRaw) { - return raw, nil - } - var provider v1.ModelProviderInput - if json.Unmarshal(providerRaw, &provider) != nil { - return nil, ErrInvalidInput - } - fingerprinted, err := s.fingerprintedProvider(&provider) - if err != nil { - return nil, err - } - if extension["model_provider"], err = json.Marshal(fingerprinted); err != nil { - return nil, err - } - if request["x_agents_core"], err = json.Marshal(extension); err != nil { - return nil, err - } - return json.Marshal(request) -} - -func jsonNull(raw json.RawMessage) bool { - return string(bytes.TrimSpace(raw)) == "null" -} - -func (s *Store) creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { - if len(raw) == 0 { - return pgtype.Text{}, nil - } - if len(raw) > 16<<20 { - return pgtype.Text{}, ErrInvalidInput - } - raw, err := s.withoutProviderKey(raw) - if err != nil { - return pgtype.Text{}, err - } - canonical, err := canonicalJSONObject(raw) - if err != nil { - return pgtype.Text{}, err - } - hash := sha256.Sum256(canonical) - return pgtype.Text{String: hex.EncodeToString(hash[:]), Valid: true}, nil -} - -// FindSessionCreation recovers recorded caller intent without resolving a mutable source. -func (s *Store) FindSessionCreation(ctx context.Context, tenantID, key string, request json.RawMessage, creator identity.Subject) (SessionCreation, error) { - if err := creator.Validate(); err != nil { - return SessionCreation{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) - } - tenant, err := parseID(tenantID) - if err != nil { - return SessionCreation{}, err - } - if strings.TrimSpace(key) == "" || len(key) > 128 { - return SessionCreation{}, ErrInvalidInput - } - hash, err := s.creationRequestHash(request) - if errors.Is(err, ErrCredentialStorageUnavailable) { - // Without the credential key no Session with a provider bundle can have - // been committed or can be created; creation reports the missing key - // after request validation. - return SessionCreation{}, ErrNotFound - } - if err != nil { - return SessionCreation{}, err - } - if !hash.Valid { - return SessionCreation{}, ErrInvalidInput - } - var row sqlc.Session - var environment *Environment - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - var err error - row, err = q.FindSessionCreation(ctx, sqlc.FindSessionCreationParams{TenantID: tenant, IdempotencyKey: key}) - if err != nil { - return err - } - environment, err = sessionEnvironmentSnapshot(ctx, q, row) - return err - }) - if errors.Is(err, pgx.ErrNoRows) { - return SessionCreation{}, ErrNotFound - } - if err != nil { - return SessionCreation{}, fmt.Errorf("find session creation: %w", err) - } - if row.DeletedAt.Valid || !row.CreatorKind.Valid || !row.CreatorID.Valid || row.CreatorKind.String != creator.Kind || row.CreatorID.String != creator.ID { - return SessionCreation{}, ErrIdempotencyConflict - } - // Missing request intent does not imply missing ownership. Known creators may - // still fall back to the original resolved-request equivalence at the upsert. - if !row.CreationRequestHash.Valid { - return SessionCreation{}, ErrNotFound - } - if row.CreationRequestHash.String != hash.String { - return SessionCreation{}, ErrIdempotencyConflict - } - session, err := sessionFromRow(row) - session.Environment = environment - // The row and cursor share one committed snapshot; later events remain observable. - return SessionCreation{Session: session, Cursor: row.EventSequence}, err -} diff --git a/services/agents-api/internal/store/session_deletion.go b/services/agents-api/internal/store/session_deletion.go deleted file mode 100644 index 51565f9fe..000000000 --- a/services/agents-api/internal/store/session_deletion.go +++ /dev/null @@ -1,106 +0,0 @@ -package store - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// ErrSessionNotIdle rejects deletion of a Session that still has work or input -// pending. Callers cancel first and delete after the Session settles. -var ErrSessionNotIdle = errors.New("session must be durably idle or failed without required actions before deletion") - -// DeleteSession removes public access to a durably idle or failed Session while -// retaining state needed to settle execution. The decision is taken under the -// Session lock that also orders Turn and input admission, so a concurrent -// admission either commits first and is rejected here, or observes the deletion. -// The owner's repeated deletion succeeds without another resource write; foreign and -// missing Sessions remain not found. -func (s *Store) DeleteSession(ctx context.Context, tenantID, sessionID string) error { - return s.withLockedSession(ctx, tenantID, sessionID, true, func(ctx context.Context, q *sqlc.Queries, session sqlc.LockSessionRow) error { - audit := func() error { - return recordWriteAudit(ctx, q, tenantID, "delete", "session", uuid.UUID(session.ID.Bytes).String(), "") - } - if session.DeletedAt.Valid { - return audit() - } - if err := requireSessionSettled(ctx, q, session.ID); err != nil { - return err - } - if err := q.DeleteSessionArtifacts(ctx, session.ID); err != nil { - return err - } - if err := q.ReleaseUnallocatedRuntimePlacement(ctx, session.ID); err != nil { - return err - } - if err := q.MarkSessionDeleted(ctx, session.ID); err != nil { - return err - } - return audit() - }) -} - -// requireSessionSettled rejects a queued, in-progress or waiting Turn, which -// includes pending required actions and function results, and a pending input -// reservation: queued later input, self-hosted input awaiting a connection, or -// hosted initial input while provisioning. Terminal idle and failed Sessions pass. -func requireSessionSettled(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - if _, err := q.GetActiveTurn(ctx, session); err == nil { - return ErrSessionNotIdle - } else if !errors.Is(err, pgx.ErrNoRows) { - return err - } - _, pending, err := environmentInputState(ctx, q, session) - if err != nil { - return err - } - if pending { - return ErrSessionNotIdle - } - return nil -} - -// cancelSessionWork requests cancellation of active work for Runtime cleanup. -func cancelSessionWork(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - turn, err := q.GetActiveTurn(ctx, session) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - if err == nil { - if err := requestTurnCancel(ctx, q, session, turn); err != nil { - return err - } - } - if err := q.CancelSessionEnvironmentInput(ctx, session); err != nil { - return err - } - return nil -} - -func requestTurnCancel(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn sqlc.Turn) error { - if err := q.RequestTurnCancel(ctx, sqlc.RequestTurnCancelParams{ID: turn.ID, SessionID: session}); err != nil { - return err - } - if turn.Status == TurnQueued { - cancelled, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) - if err != nil { - return err - } - if err := recordTurnChange(ctx, q, cancelled, false); err != nil { - return err - } - } else if turn.Status == TurnWaiting && !turn.CancelRequestedAt.Valid { - cancelling, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) - if err != nil { - return err - } - if err := recordSessionActivity(ctx, q, cancelling, nil); err != nil { - return err - } - } - return nil -} diff --git a/services/agents-api/internal/store/session_diagnostics.go b/services/agents-api/internal/store/session_diagnostics.go deleted file mode 100644 index 11ea8f9bc..000000000 --- a/services/agents-api/internal/store/session_diagnostics.go +++ /dev/null @@ -1,100 +0,0 @@ -package store - -import ( - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// ItemDiagnosticTiming records Core database receipt and settlement, never native -// execution duration. Historical terminal Items can have unknown settlement. -type ItemDiagnosticTiming struct { - ItemID string - StartedAt time.Time - CompletedAt *time.Time -} - -type TurnDiagnosticsSnapshot struct { - Session Session - Turn Turn - Items []ItemDiagnosticTiming - ItemsTruncated bool -} - -// GetSessionDiagnosticsSnapshot keeps all existing Session projections in one -// read-only snapshot, including the failure precedence used by the public API. -func (s *Store) GetSessionDiagnosticsSnapshot(ctx context.Context, tenantID, sessionID string) (Session, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Session{}, err - } - var session Session - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: tenant, ID: parsePathID(sessionID)}) - if err != nil { - return err - } - session, err = sessionFromRow(row) - if err != nil { - return err - } - session, err = readSessionActivity(ctx, q, session) - return err - }) - if errors.Is(err, pgx.ErrNoRows) { - return Session{}, ErrNotFound - } - return session, err -} - -// GetTurnDiagnosticsSnapshot reads only root Turns and their root Items. Its -// bounded query and public projection share the same committed snapshot. -func (s *Store) GetTurnDiagnosticsSnapshot(ctx context.Context, tenantID, sessionID, turnID string) (TurnDiagnosticsSnapshot, error) { - params, err := publicTurnLookup(tenantID, sessionID, turnID) - if err != nil { - return TurnDiagnosticsSnapshot{}, err - } - result := TurnDiagnosticsSnapshot{Items: []ItemDiagnosticTiming{}} - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - turn, err := q.GetTurn(ctx, params) - if err != nil { - return err - } - row, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: params.TenantID, ID: params.SessionID}) - if err != nil { - return err - } - result.Session, err = sessionFromRow(row) - if err != nil { - return err - } - result.Turn = turnFromRow(turn) - rows, err := q.ListTurnItemDiagnostics(ctx, sqlc.ListTurnItemDiagnosticsParams{SessionID: params.SessionID, TurnID: params.ID}) - if err != nil { - return err - } - result.ItemsTruncated = len(rows) > 1000 - if result.ItemsTruncated { - rows = rows[:1000] - } - for _, row := range rows { - item := ItemDiagnosticTiming{ItemID: uuid.UUID(row.ID.Bytes).String(), StartedAt: row.CreatedAt.Time} - if row.SettledAt.Valid { - at := row.SettledAt.Time - item.CompletedAt = &at - } - result.Items = append(result.Items, item) - } - return nil - }) - if errors.Is(err, pgx.ErrNoRows) { - return TurnDiagnosticsSnapshot{}, ErrNotFound - } - return result, err -} diff --git a/services/agents-api/internal/store/session_diagnostics_test.go b/services/agents-api/internal/store/session_diagnostics_test.go deleted file mode 100644 index 07f8a536d..000000000 --- a/services/agents-api/internal/store/session_diagnostics_test.go +++ /dev/null @@ -1,368 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -func diagnosticToolEvent(id, stage, status string) ExecutionEvent { - return ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(fmt.Sprintf(`{"id":%q,"stage":%q,"observation":{"kind":"command","command":"private-command-canary","status":%q}}`, id, stage, status))} -} - -func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { - s, _ := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) - before := diagnosticToolEvent("cmd", "before", "in_progress") - after := diagnosticToolEvent("cmd", "after", "failed") - for i, event := range []ExecutionEvent{before, after} { - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, int32(i+1), []ExecutionEvent{event}); err != nil { - t.Fatal(err) - } - } - snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || len(snap.Items) != 2 { - t.Fatal(snap, err) - } - if snap.Items[0].CompletedAt == nil || !snap.Items[0].CompletedAt.Equal(snap.Items[0].StartedAt) { - t.Fatal("terminal input did not settle at its receipt", snap.Items[0]) - } - item := snap.Items[1] - var received time.Time - if err := s.pool.QueryRow(t.Context(), "SELECT created_at FROM turn_events WHERE turn_id=$1 AND ordinal=2", receipt.TurnID).Scan(&received); err != nil { - t.Fatal(err) - } - if item.CompletedAt == nil || !item.CompletedAt.Equal(received) || item.CompletedAt.Before(item.StartedAt) { - t.Fatal("Item did not use terminal receipt", item, received) - } - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 2, []ExecutionEvent{after}); err != nil { - t.Fatal(err) - } - // A terminal legacy Item with unknown settlement must remain unknown even on a repeated upsert. - runtimeSuspensionSQL(t, s.pool, "UPDATE session_items SET settled_at=NULL WHERE id=$1", item.ItemID) - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, []ExecutionEvent{after}); err != nil { - t.Fatal(err) - } - transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) - snap, err = s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || snap.Items[1].CompletedAt != nil { - t.Fatal("historical settlement synthesized", snap, err) - } -} - -func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { - for _, skew := range []time.Duration{-269 * time.Second, 269 * time.Second} { - t.Run(skew.String(), func(t *testing.T) { - s, w, owner := managedIdleClockFixture(t) - turn := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at) VALUES($1,$2,'in_progress',clock_timestamp())", turn, owner.SessionID) - events := []ExecutionEvent{diagnosticToolEvent("first", "before", "in_progress"), diagnosticToolEvent("second", "before", "in_progress")} - if err := w.AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { - t.Fatal(err) - } - source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() - before := runtimeDatabaseTime(t, s) - completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) - after := runtimeDatabaseTime(t, s) - if err != nil || completed.CompletedAt.UnixMilli() != source { - t.Fatal("public native completion changed", completed, err) - } - snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn) - if err != nil || len(snap.Items) != 2 { - t.Fatal(snap, err) - } - for _, item := range snap.Items { - if item.CompletedAt == nil || item.CompletedAt.Before(before) || item.CompletedAt.After(after) || item.CompletedAt.Before(item.StartedAt) { - t.Fatal("force settlement used remote or transaction-start clock", item) - } - } - if !snap.Items[0].CompletedAt.Equal(*snap.Items[1].CompletedAt) { - t.Fatal("force settlement has multiple clocks") - } - }) - } -} - -func TestDiagnosticSettlementWaitsForSessionLock(t *testing.T) { - s, pool := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) - tx, err := pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - if _, err = tx.Exec(t.Context(), "SELECT id FROM sessions WHERE id=$1 FOR UPDATE", session.ID); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { - _, err := s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) - done <- err - }() - // Wait for the actual competing transaction to block, not a scheduler delay. - deadline := time.After(5 * time.Second) - for { - var waiting bool - err = pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%sessions%')").Scan(&waiting) - if err != nil { - t.Fatal(err) - } - if waiting { - break - } - select { - case err := <-done: - t.Fatal("terminal transaction did not wait", err) - case <-deadline: - t.Fatal("terminal transaction never reached lock") - case <-time.After(10 * time.Millisecond): - } - } - itemID := uuid.NewString() - _, err = tx.Exec(t.Context(), "INSERT INTO session_items(id,session_id,turn_id,created_at,position,payload) VALUES($1,$2,$3,clock_timestamp(),1,$4)", itemID, session.ID, receipt.TurnID, `{"id":"`+itemID+`","turn_id":"`+receipt.TurnID+`","type":"command_execution","status":"in_progress","command":"run"}`) - if err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if err = <-done; err != nil { - t.Fatal(err) - } - snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil { - t.Fatal(err) - } - item := snap.Items[len(snap.Items)-1] - if item.CompletedAt == nil || item.CompletedAt.Before(item.StartedAt) { - t.Fatal("transaction-start settlement predates serialized Item receipt", item) - } -} - -func TestDiagnosticTimingBoundOrderAndIsolation(t *testing.T) { - s, pool := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - // Insert in reverse position order with equal times; response must follow public Item ordering. - _, err := pool.Exec(t.Context(), `INSERT INTO session_items(id,session_id,turn_id,created_at,position,payload) - SELECT gen_random_uuid(),$1,$2,'2020-01-01T00:00:00Z'::timestamptz,n,'{"status":"completed"}'::jsonb FROM generate_series(1000,1,-1) n`, session.ID, receipt.TurnID) - if err != nil { - t.Fatal(err) - } - snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || len(snap.Items) != 1000 || !snap.ItemsTruncated { - t.Fatal("unbounded diagnostics", len(snap.Items), snap.ItemsTruncated, err) - } - for i, item := range snap.Items { - var position int - if err := pool.QueryRow(t.Context(), "SELECT position FROM session_items WHERE id=$1", item.ItemID).Scan(&position); err != nil { - t.Fatal(err) - } - if position != i+1 || item.CompletedAt != nil { - t.Fatal("order or historical settlement changed", position, item) - } - } - runtimeSuspensionSQL(t, pool, "DELETE FROM session_items WHERE turn_id=$1 AND created_at>'2020-01-01T00:00:00Z'", receipt.TurnID) - exact, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || len(exact.Items) != 1000 || exact.ItemsTruncated { - t.Fatal("exact limit falsely truncated", len(exact.Items), exact.ItemsTruncated, err) - } - for _, ids := range [][3]string{{uuid.NewString(), session.ID, receipt.TurnID}, {tenant, "malformed", receipt.TurnID}, {tenant, session.ID, uuid.NewString()}} { - if _, err := s.GetTurnDiagnosticsSnapshot(t.Context(), ids[0], ids[1], ids[2]); !errors.Is(err, ErrNotFound) { - t.Fatal("scope leaked", ids, err) - } - } - runtimeSuspensionSQL(t, pool, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID) - if _, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted root visible", err) - } - if _, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted Session visible", err) - } -} - -func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) { - s, pool := testStore(t) - tenant := uuid.NewString() - input := environmentInput("safe-detail", "openai_hosted", "/workspace") - input.InitialInputs = []Input{messageInput("initial")} - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - writer := executionLease(t, s).Store() - owner, err := writer.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - constraint := "diagnostics_" + strings.ReplaceAll(uuid.NewString(), "-", "") - _, err = pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR payload->'event'->>'type' <> 'agent.session.failed') NOT VALID") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) - }) - failure := ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 2, ExitCode: 7} - runtimeSuspensionSQL(t, pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID) - preparation := EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} - if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err == nil { - t.Fatal("failure committed without events") - } - var detail []byte - if err = pool.QueryRow(t.Context(), "SELECT failure_detail FROM environments WHERE id=$1", owner.EnvironmentID).Scan(&detail); err != nil || detail != nil { - t.Fatal("partial failure detail", string(detail), err) - } - runtimeSuspensionSQL(t, pool, "ALTER TABLE session_events DROP CONSTRAINT "+constraint) - if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err != nil { - t.Fatal(err) - } - snap, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) - if err != nil || snap.EnvironmentFailure == nil || snap.EnvironmentFailure.Detail == nil || *snap.EnvironmentFailure.Detail.Index != 2 || *snap.EnvironmentFailure.Detail.ExitCode != 7 { - t.Fatal("detail not persisted", snap.EnvironmentFailure, err) - } - events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(events) - if strings.Contains(string(raw), "exit_code") || strings.Contains(string(raw), "failure_detail") { - t.Fatal("private fields entered SSE", string(raw)) - } - // Historical reasons are never parsed into structured detail; corrupted private fields are sanitized. - runtimeSuspensionSQL(t, pool, "UPDATE environments SET failure_detail=$2 WHERE id=$1", owner.EnvironmentID, `{"step":"secret-provider-token","index":3,"exit_code":2}`) - snap, err = s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) - if err != nil || snap.EnvironmentFailure.Detail != nil { - t.Fatal("unsafe private detail projected", snap.EnvironmentFailure, err) - } -} - -func TestDiagnosticPutItemRollsBack(t *testing.T) { - s, pool := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - tx, err := pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - id, _ := parseID(uuid.NewString()) - sid, _ := parseID(session.ID) - tid, _ := parseID(receipt.TurnID) - _, err = sqlc.New(tx).PutSessionItem(t.Context(), sqlc.PutSessionItemParams{ID: id, SessionID: sid, TurnID: tid, CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, Payload: []byte(`{"status":"completed"}`)}) - if err != nil { - t.Fatal(err) - } - if err = tx.Rollback(t.Context()); err != nil { - t.Fatal(err) - } - snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || len(snap.Items) != 1 { - t.Fatal("rolled back receipt visible", snap, err) - } -} - -func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { - s, pool := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) - events := []ExecutionEvent{diagnosticToolEvent("cmd", "before", "in_progress"), diagnosticToolEvent("cmd", "after", "completed")} - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 1, events); err != nil { - t.Fatal(err) - } - first, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil { - t.Fatal(err) - } - item := first.Items[1] - if item.CompletedAt == nil { - t.Fatal("missing first settlement") - } - // Exercise persistence defensively even if a producer bypasses the normal - // Item merge's terminal-status preservation. - for _, force := range []bool{false, true} { - runtimeSuspensionSQL(t, pool, "UPDATE session_items SET payload=jsonb_set(payload,'{status}','\"in_progress\"') WHERE id=$1", item.ItemID) - if force { - transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) - } else if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, events[1:]); err != nil { - t.Fatal(err) - } - got, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) - if err != nil || got.Items[1].CompletedAt == nil || !got.Items[1].CompletedAt.Equal(*item.CompletedAt) { - t.Fatal("first settlement overwritten", force, got, err) - } - } -} - -func TestDiagnosticRootReadRejectsActualChildTurn(t *testing.T) { - s, w, owner := managedIdleClockFixture(t) - root := runtimeSuspensionCompleted(t, s.pool, owner) - child, turn := uuid.NewString(), uuid.NewString() - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, root) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, root) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn','in_progress',clock_timestamp())`, turn, owner.SessionID, child) - if _, err := w.GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn); !errors.Is(err, ErrNotFound) { - t.Fatal("child Turn became root diagnostics", err) - } -} - -func TestDiagnosticSessionSnapshotConcurrentCommitConsistency(t *testing.T) { - s, pool := testStore(t) - tenant, session := newTurnSession(t, s) - receipt := submitMessage(t, s, tenant, session.ID, "start") - runtimeSuspensionSQL(t, pool, `UPDATE sessions SET metadata='{"generation":"0"}' WHERE id=$1`, session.ID) - runtimeSuspensionSQL(t, pool, `UPDATE turns SET outcome='{"generation":"0"}' WHERE id=$1`, receipt.TurnID) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { - for i := 1; i <= 150; i++ { - tx, err := pool.Begin(ctx) - if err != nil { - done <- err - return - } - raw := fmt.Sprintf(`{"generation":"%d"}`, i) - _, err = tx.Exec(ctx, "UPDATE sessions SET metadata=$2 WHERE id=$1", session.ID, raw) - if err == nil { - _, err = tx.Exec(ctx, "UPDATE turns SET outcome=$2 WHERE id=$1", receipt.TurnID, raw) - } - if err != nil { - _ = tx.Rollback(context.Background()) - done <- err - return - } - if err = tx.Commit(ctx); err != nil { - done <- err - return - } - } - done <- nil - }() - for i := 0; i < 150; i++ { - snapshot, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - var outcome map[string]string - if snapshot.LastTurn == nil || json.Unmarshal(snapshot.LastTurn.Outcome, &outcome) != nil || outcome["generation"] != snapshot.Metadata["generation"] { - t.Fatal("mixed committed snapshots", snapshot.Metadata, outcome) - } - } - if err := <-done; err != nil { - t.Fatal(err) - } -} diff --git a/services/agents-api/internal/store/session_execution_configuration.go b/services/agents-api/internal/store/session_execution_configuration.go deleted file mode 100644 index 31642a762..000000000 --- a/services/agents-api/internal/store/session_execution_configuration.go +++ /dev/null @@ -1,136 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// The projection is creation metadata, excluded from retry identity. Only the -// transaction that inserts the Session writes it; retries cannot repair or alter it. -func saveSessionExecutionConfiguration(ctx context.Context, q *sqlc.Queries, session sqlc.Session, projection *v1.SessionExecutionConfiguration, provider *v1.ModelProviderInput, providerSource string, revision uuid.UUID) error { - if projection == nil { - return nil - } - var frozenRevision pgtype.UUID - frozen := *projection - model, err := sessionExecutionModel(session.Configuration) - if err != nil { - return err - } - if !sameExecutionValue(frozen.Model.Value, model) || frozen.Harness.Value == nil || *frozen.Harness.Value != session.Engine || - !validExecutionSource(frozen.Model.Source) || !validExecutionSource(frozen.Harness.Source) { - return fmt.Errorf("%w: execution projection does not match Session configuration", ErrInvalidInput) - } - switch frozen.ModelProvider.Source { - case "deployment": - if provider == nil { - return fmt.Errorf("%w: execution projection has no model provider", ErrInvalidInput) - } - // The deployment default is readable with the same Core key, so the - // safe view is recorded from the frozen bundle itself. Native options - // are never part of it. - frozen.ModelProvider.Status = "available" - frozen.ModelProvider.Configuration = provider.SafeView() - if providerSource == v1.ModelProviderSourceDeployment && revision != uuid.Nil { - frozenRevision = pgtype.UUID{Bytes: revision, Valid: true} - } - case "session", "agent": - if provider == nil || frozen.ModelProvider.Status != "available" || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { - return fmt.Errorf("%w: execution projection does not match model provider", ErrInvalidInput) - } - case "unknown": - frozen.ModelProvider.Status = "unavailable" - frozen.ModelProvider.Configuration = nil - default: - return fmt.Errorf("%w: invalid execution projection source", ErrInvalidInput) - } - normalizeExecutionProjection(&frozen, uuid.UUID(session.ID.Bytes).String()) - raw, err := json.Marshal(frozen) - if err != nil { - return err - } - return q.SaveSessionExecutionConfiguration(ctx, sqlc.SaveSessionExecutionConfigurationParams{SessionID: session.ID, Configuration: raw, DeploymentProviderRevision: frozenRevision}) -} - -// GetSessionExecutionConfiguration reads only safe committed configuration. It -// never loads provider ciphertext, current defaults or runtime health. -func (s *Store) GetSessionExecutionConfiguration(ctx context.Context, tenantID, sessionID string) (v1.SessionExecutionConfiguration, error) { - tenant, err := parseID(tenantID) - if err != nil { - return v1.SessionExecutionConfiguration{}, err - } - row, err := s.queries.GetSessionExecutionConfiguration(ctx, sqlc.GetSessionExecutionConfigurationParams{TenantID: tenant, SessionID: parsePathID(sessionID)}) - if errors.Is(err, pgx.ErrNoRows) { - return v1.SessionExecutionConfiguration{}, ErrNotFound - } - if err != nil { - return v1.SessionExecutionConfiguration{}, fmt.Errorf("get session execution configuration: %w", err) - } - var projection v1.SessionExecutionConfiguration - if len(row.ExecutionConfiguration) == 0 { - model, err := sessionExecutionModel(row.SessionConfiguration) - if err != nil { - return projection, err - } - var harness *string - if row.Engine != "" { - harness = &row.Engine - } - projection.Model = v1.ExecutionSelection{Value: model, Source: "unknown"} - projection.Harness = v1.ExecutionSelection{Value: harness, Source: "unknown"} - projection.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} - } else if err := json.Unmarshal(row.ExecutionConfiguration, &projection); err != nil { - return v1.SessionExecutionConfiguration{}, errors.New("invalid stored session execution configuration") - } - normalizeExecutionProjection(&projection, uuid.UUID(row.ID.Bytes).String()) - return projection, nil -} - -func normalizeExecutionProjection(projection *v1.SessionExecutionConfiguration, sessionID string) { - projection.Object = "agent.session.execution_configuration" - projection.SchemaVersion = 1 - if projection.HarnessConfig.Source == "" { - projection.HarnessConfig.Source = "unknown" - } - projection.HarnessConfig.Value = v1.ResolvedHarnessConfig(projection.HarnessConfig.Value) - projection.SessionID = sessionID - if projection.ModelProvider.Source == "deployment" && (projection.ModelProvider.Status != "available" || projection.ModelProvider.Configuration == nil) { - // Sessions created before deployment defaults moved into Core stay redacted. - projection.ModelProvider.Status = "redacted" - projection.ModelProvider.Configuration = nil - } else if projection.ModelProvider.Status != "available" { - projection.ModelProvider.Configuration = nil - } -} - -func sessionExecutionModel(configuration []byte) (*string, error) { - var config struct { - Agent struct { - Model *string `json:"model"` - } `json:"agent"` - } - if err := json.Unmarshal(configuration, &config); err != nil { - return nil, errors.New("invalid stored Session model configuration") - } - return config.Agent.Model, nil -} - -func sameExecutionValue(a, b *string) bool { - return a == nil && b == nil || a != nil && b != nil && *a == *b -} - -func validExecutionSource(source string) bool { - switch source { - case "session", "agent", "deployment", "unknown": - return true - } - return false -} diff --git a/services/agents-api/internal/store/session_execution_configuration_test.go b/services/agents-api/internal/store/session_execution_configuration_test.go deleted file mode 100644 index b24683700..000000000 --- a/services/agents-api/internal/store/session_execution_configuration_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "reflect" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func executionProjectionInput(source string) CreateSessionInput { - model, harness := "frozen-model", "codex" - return CreateSessionInput{ - Creator: FixtureCreator(), Engine: harness, IdempotencyKey: uuid.NewString(), - Configuration: []byte(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`), - ExecutionConfiguration: &v1.SessionExecutionConfiguration{ - Model: v1.ExecutionSelection{Value: &model, Source: source}, - Harness: v1.ExecutionSelection{Value: &harness, Source: source}, - ModelProvider: v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"}, - }, - } -} - -func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - for _, stream := range []bool{false, true} { - for _, source := range []string{"session", "agent", "deployment"} { - t.Run(source+map[bool]string{false: "/ordinary", true: "/stream"}[stream], func(t *testing.T) { - tenant := uuid.NewString() - input := executionProjectionInput(source) - input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://private-deployment.example/v1", APIKey: "private-projection-key-canary"} - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} - input.ExecutionConfiguration.Object = "untrusted-object" - input.ExecutionConfiguration.SchemaVersion = 99 - input.ExecutionConfiguration.SessionID = "untrusted-session" - var session Session - var err error - if stream { - created, e := s.CreateSessionStream(t.Context(), tenant, input) - session, err = created.Session, e - } else { - session, err = s.CreateSession(t.Context(), tenant, input) - } - if err != nil { - t.Fatal(err) - } - // A reader without the encryption key can use the safe snapshot after restart. - reader := New(pool) - frozen, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != source || frozen.Harness.Source != source { - t.Fatal("incorrect frozen projection identity or provenance") - } - // Deployment defaults are readable with the same Core key, so every - // source records the safe view of the frozen bundle. - if frozen.ModelProvider.Status != "available" || !reflect.DeepEqual(frozen.ModelProvider.Configuration, input.ModelProvider.SafeView()) { - t.Fatal("safe provider projection changed") - } - var stored []byte - if err := pool.QueryRow(t.Context(), "SELECT configuration FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&stored); err != nil { - t.Fatal(err) - } - for _, secret := range []string{"private-projection-key-canary", "native_options"} { - if bytes.Contains(stored, []byte(secret)) { - t.Fatal("secret entered safe projection") - } - } - // Source-only changes and invalid replacement metadata never alter the - // existing Session's retry identity or projection. - input.ExecutionConfiguration.Model.Source = "different-on-retry" - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} - replay, err := s.CreateSession(t.Context(), tenant, input) - if err != nil || replay.ID != session.ID { - t.Fatal("projection metadata changed retry identity", err) - } - if _, err := s.UpdateSessionMetadata(t.Context(), tenant, session.ID, map[string]string{"edited": "yes"}); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "UPDATE session_model_execution SET encrypted_config='\\x00'::bytea WHERE session_id=$1", session.ID); err != nil { - t.Fatal(err) - } - got, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(got, frozen) { - t.Fatal("snapshot changed or reader decrypted a secret", err) - } - if source == "deployment" { - // Sessions frozen before deployment defaults moved into Core stay redacted. - if _, err := pool.Exec(t.Context(), `UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model_provider}', '{"source":"deployment","status":"redacted","configuration":null}') WHERE session_id=$1`, session.ID); err != nil { - t.Fatal(err) - } - if historical, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); err != nil || historical.ModelProvider.Status != "redacted" || historical.ModelProvider.Configuration != nil { - t.Fatal("historical deployment selection changed", err) - } - } - for _, lookup := range []struct{ tenant, id string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}, {tenant, "malformed"}} { - if _, err := reader.GetSessionExecutionConfiguration(t.Context(), lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign/missing projection read differed", err) - } - } - if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { - t.Fatal(err) - } - if _, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted Session projection remained public", err) - } - }) - } - } -} - -func TestSessionExecutionConfigurationHistoricalProvenance(t *testing.T) { - s, pool := testStore(t) - tenant := uuid.NewString() - for _, configuration := range []string{`{}`, `{"agent":{"model":null}}`, `{"agent":{"model":"historical-model"},"model_provider_configured":true}`} { - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(configuration)} - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - if got.Model.Source != "unknown" || got.Harness.Source != "unknown" || got.Harness.Value == nil || *got.Harness.Value != "codex" || got.ModelProvider.Source != "unknown" || got.ModelProvider.Status != "unavailable" || got.ModelProvider.Configuration != nil { - t.Fatal("historical provenance guessed") - } - if bytes.Contains([]byte(configuration), []byte("historical-model")) { - if got.Model.Value == nil || *got.Model.Value != "historical-model" { - t.Fatal("historical model lost") - } - } else if got.Model.Value != nil { - t.Fatal("missing historical model invented") - } - input.ExecutionConfiguration = executionProjectionInput("session").ExecutionConfiguration - if _, err := s.CreateSession(t.Context(), tenant, input); err != nil { - t.Fatal("historical retry changed", err) - } - var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&count); err != nil || count != 0 { - t.Fatal("historical snapshot was backfilled", err) - } - } -} - -func TestSessionExecutionConfigurationRollbackAndValidation(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{42}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - tenant := uuid.NewString() - for _, kind := range []string{"model", "harness", "source", "provider", "provider_mismatch", "post_projection_failure"} { - input := executionProjectionInput("session") - switch kind { - case "model": - value := "different-model" - input.ExecutionConfiguration.Model.Value = &value - case "harness": - value := "different-harness" - input.ExecutionConfiguration.Harness.Value = &value - case "source": - input.ExecutionConfiguration.Model.Source = "invalid" - case "provider": - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: &v1.ModelProviderView{Protocol: "responses", BaseURL: "https://example.com"}} - case "provider_mismatch": - input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://actual.example/v1", APIKey: "private-key-canary"} - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} - input.ExecutionConfiguration.ModelProvider.Configuration.BaseURL = "https://different.example/v1" - case "post_projection_failure": - input.InitialFiles = []InitialFile{{Type: "inline", Path: "invalid-path"}} - } - if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("%s: invalid projection/creation accepted: %v", kind, err) - } - } - var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { - t.Fatal("rejected projection left Session", err) - } - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration p LEFT JOIN sessions s ON s.id=p.session_id WHERE s.id IS NULL").Scan(&count); err != nil || count != 0 { - t.Fatal("rejected creation left orphan projection", err) - } -} - -func TestSessionExecutionConfigurationConcurrentRetryKeepsWinner(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := executionProjectionInput("session") - var wg sync.WaitGroup - projections := make(chan v1.SessionExecutionConfiguration, 8) - for i := 0; i < 8; i++ { - request := input - projection := *input.ExecutionConfiguration - if i%2 == 1 { - projection.Model.Source = "agent" - projection.Harness.Source = "deployment" - } - request.ExecutionConfiguration = &projection - wg.Add(1) - go func() { - defer wg.Done() - created, err := s.CreateSessionStream(t.Context(), tenant, request) - if err != nil { - t.Error(err) - return - } - got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, created.Session.ID) - if err != nil { - t.Error(err) - return - } - projections <- got - }() - } - wg.Wait() - close(projections) - var winner *v1.SessionExecutionConfiguration - for got := range projections { - if winner == nil { - winner = &got - } else if !reflect.DeepEqual(*winner, got) { - t.Fatal("concurrent retry rewrote provenance") - } - } - if winner == nil { - t.Fatal("no committed projection") - } -} - -func TestSessionExecutionConfigurationSurvivesSuspendResume(t *testing.T) { - s, pool := testStore(t) - w := executionLease(t, s).Store() - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, executionProjectionInput("agent")) - if err != nil { - t.Fatal(err) - } - frozen, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - owner, err = w.ObserveRuntimeRunning(t.Context(), owner) - if err != nil { - t.Fatal(err) - } - owner, err = w.SetRuntimeCompute(t.Context(), owner, "running", json.RawMessage(`{"instance":"original"}`), nil, 0) - if err != nil { - t.Fatal(err) - } - runtimeSuspensionCompleted(t, pool, owner) - until := time.Now().Add(time.Hour) - for _, phase := range []string{"quiescing", "suspending", "suspended", "restoring", "waking", "running"} { - retained := &until - if phase == "running" { - retained = nil - } - owner = runtimeSuspensionStep(t, w, owner, phase, retained) - before, err := w.RuntimeActivity(t.Context(), owner) - if err != nil { - t.Fatal(err) - } - got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) - if err != nil || !reflect.DeepEqual(got, frozen) { - t.Fatal("runtime transition changed execution projection", phase, err) - } - after, err := w.RuntimeActivity(t.Context(), owner) - if err != nil || !before.LastActivity.Equal(after.LastActivity) || before.WakeRequested != after.WakeRequested || before.Busy != after.Busy || before.HasCompletedTurn != after.HasCompletedTurn { - t.Fatal("configuration read changed runtime activity", err) - } - } -} diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go deleted file mode 100644 index ecad2ba78..000000000 --- a/services/agents-api/internal/store/session_initial_input.go +++ /dev/null @@ -1,148 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { - for _, input := range inputs { - if input.Kind != "message" { - return nil, nil, ErrInvalidInput - } - } - return validateInputs(inputs) -} - -// The Session upsert locks retries. Only the new row reserves or admits work, so a -// retry after completion or later Turns cannot submit the original input again. -func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, executionConfiguration *v1.SessionExecutionConfiguration, providerSource string, deploymentRevision uuid.UUID) (sqlc.Session, *Environment, error) { - var row sqlc.Session - var environment *Environment - err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - var err error - row, err = q.CreateSession(ctx, params) - if err != nil { - return err - } - if row.ID == params.ID { - var placement struct { - Environment struct { - Type string `json:"type"` - } `json:"environment"` - } - if err := json.Unmarshal(row.Configuration, &placement); err != nil { - return err - } - if placement.Environment.Type == "openai_hosted" { - if err := checkRuntimeDeploymentAdmission(ctx, q, ""); err != nil { - return err - } - } - setup, err = s.freezeEnvironmentSkills(ctx, q, tenant, setup) - if err != nil { - return err - } - if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider); err != nil { - return err - } - if err := saveSessionExecutionConfiguration(ctx, q, row, executionConfiguration, provider, providerSource, deploymentRevision); err != nil { - return err - } - if len(files) > 0 { - metadata, err := s.saveInitialFiles(ctx, q, tx, tenant, row.ID, files) - if err != nil { - return err - } - row, err = q.SetSessionInitialFileMetadata(ctx, sqlc.SetSessionInitialFileMetadataParams{ID: row.ID, Column2: metadata}) - if err != nil { - return err - } - } - if err := s.saveEnvironmentSetup(ctx, q, tenant, row.ID, setup); err != nil { - return err - } - if !setup.Empty() { - packages, err := json.Marshal(setup.PackageMetadata()) - if err != nil { - return err - } - skills, err := json.Marshal(setup.SkillMetadata()) - if err != nil { - return err - } - plugins, err := json.Marshal(setup.PluginMetadata()) - if err != nil { - return err - } - directories, err := json.Marshal(append([]string{}, setup.CapabilityDirectories...)) - if err != nil { - return err - } - row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Packages: packages, Skills: skills, Plugins: plugins, CapabilityDirectories: directories}) - if err != nil { - return err - } - } - if err := createSessionEnvironment(ctx, q, row); err != nil { - return err - } - if placement.Environment.Type == "openai_hosted" { - if err := reserveRuntimePlacement(ctx, q, row.ID, s.publicURL); err != nil { - return err - } - } - } - environment, err = sessionEnvironmentSnapshot(ctx, q, row) - if err != nil { - return err - } - audit := func() error { - var created []AuditResource - if row.ID == params.ID { - created = append(created, AuditResource{Type: "session", ID: uuid.UUID(row.ID.Bytes).String()}) - if environment != nil { - created = append(created, AuditResource{Type: "environment", ID: environment.ID, ParentID: uuid.UUID(row.ID.Bytes).String()}) - } - } - return recordWriteAudit(ctx, q, tenant, "create", "session", uuid.UUID(row.ID.Bytes).String(), "", created...) - } - if row.ID != params.ID || len(inputs) == 0 { - return audit() - } - // Creation retries use the Session request hash. Keep the internal input key - // independent of caller-supplied keys at the events endpoint. - key := uuid.NewString() - if environment != nil { - // Environment input waits for the existing preparation and leased promotion. - if err := withEnvironmentInputActivity(ctx, q, row.ID, func() error { - _, err := q.CreateEnvironmentInputReservation(ctx, sqlc.CreateEnvironmentInputReservationParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: row.ID, - IdempotencyKey: key, Batch: encodedInput, IsInitial: true, - }) - return err - }); err != nil { - return err - } - } else { - for position, input := range inputs { - if _, err := admitInput(ctx, q, tenant, row.ID, key, int32(position), input); err != nil { - return err - } - } - } - if err := q.PruneSessionEvents(ctx, row.ID); err != nil { - return err - } - return audit() - }) - return row, environment, err -} diff --git a/services/agents-api/internal/store/session_write_audit.go b/services/agents-api/internal/store/session_write_audit.go deleted file mode 100644 index b31db8b36..000000000 --- a/services/agents-api/internal/store/session_write_audit.go +++ /dev/null @@ -1,20 +0,0 @@ -package store - -import ( - "context" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -// AuditSessionOperation records an authorized public no-op or creation replay. -// It cannot create ownership or admit execution work. -func (s *Store) AuditSessionOperation(ctx context.Context, tenantID, sessionID, action string) error { - if action != "create" && action != "send_events" { - return ErrInvalidInput - } - return s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - return recordWriteAudit(ctx, q, tenantID, action, "session", uuid.UUID(session.Bytes).String(), "") - }) -} diff --git a/services/agents-api/internal/store/session_write_audit_test.go b/services/agents-api/internal/store/session_write_audit_test.go deleted file mode 100644 index a8ae2f8be..000000000 --- a/services/agents-api/internal/store/session_write_audit_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" - "github.com/google/uuid" -) - -func sessionAuditContext(t *testing.T, tenant, key string) context.Context { - t.Helper() - digest := strings.Repeat(key, 64) - return writeaudit.WithSource(t.Context(), writeaudit.Source{TenantID: tenant, KeyID: "static:" + digest, - Name: "safe key", Prefix: digest[:8], Kind: "static", RequestID: uuid.NewString(), TraceID: "shared-trace"}) -} - -func sessionAuditCount(t *testing.T, s *Store, tenant string, want int) { - t.Helper() - var count int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != want { - t.Fatalf("audit count = %d, want %d: %v", count, want, err) - } -} - -func rejectSessionAudit(t *testing.T, s *Store, ctx context.Context) { - t.Helper() - source, _ := writeaudit.FromContext(ctx) - name := "reject_session_audit_" + strings.ReplaceAll(uuid.NewString(), "-", "") - _, err := s.pool.Exec(t.Context(), fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ -BEGIN IF NEW.request_id = '%s' THEN RAISE EXCEPTION 'audit insert rejected'; END IF; RETURN NEW; END $$; -CREATE TRIGGER %s BEFORE INSERT ON write_audit_operations FOR EACH ROW EXECUTE FUNCTION %s();`, name, source.RequestID, name, name)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if _, err := s.pool.Exec(context.Background(), fmt.Sprintf("DROP TRIGGER %s ON write_audit_operations; DROP FUNCTION %s();", name, name)); err != nil { - t.Error(err) - } - }) -} - -func TestSessionWriteAuditCreationReplayNoopAndDeletion(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := environmentInput("audit-create", "self_hosted", "/workspace") - created, err := s.CreateSession(sessionAuditContext(t, tenant, "a"), tenant, input) - if err != nil { - t.Fatal(err) - } - env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) - if err != nil { - t.Fatal(err) - } - if _, err := s.CreateSession(sessionAuditContext(t, tenant, "b"), tenant, input); err != nil { - t.Fatal(err) - } - if _, err := s.GetSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { - t.Fatal(err) - } - sessionAuditCount(t, s, tenant, 2) - for _, resource := range []string{created.ID, env.ID} { - var key string - if err := s.pool.QueryRow(t.Context(), `SELECT o.key_id FROM write_audit_owners a JOIN write_audit_operations o ON o.id=a.operation_id WHERE a.tenant_id=$1 AND a.resource_id=$2`, tenant, resource).Scan(&key); err != nil || key != "static:"+strings.Repeat("a", 64) { - t.Fatal("retry replaced creator", key, err) - } - } - for _, action := range []string{"create", "send_events"} { - if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, action); err != nil { - t.Fatal(err) - } - } - if _, err := s.UpdateSessionMetadata(sessionAuditContext(t, tenant, "b"), tenant, created.ID, map[string]string{"private": "not in audit"}); err != nil { - t.Fatal(err) - } - for range 2 { - if err := s.DeleteSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { - t.Fatal(err) - } - } - sessionAuditCount(t, s, tenant, 7) - var history string - if err := s.pool.QueryRow(t.Context(), "SELECT jsonb_agg(to_jsonb(o))::text FROM write_audit_operations o WHERE tenant_id=$1", tenant).Scan(&history); err != nil || strings.Contains(history, "not in audit") || strings.Contains(history, "/workspace") { - t.Fatal("payload entered audit", err) - } - if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, "send_events"); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted no-op accepted", err) - } - sessionAuditCount(t, s, tenant, 7) -} - -func TestSessionWriteAuditConcurrentCreation(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := environmentInput("audit-race", "self_hosted", "/workspace") - var group sync.WaitGroup - for range 8 { - ctx := sessionAuditContext(t, tenant, "a") - group.Go(func() { - if _, err := s.CreateSession(ctx, tenant, input); err != nil { - t.Error(err) - } - }) - } - group.Wait() - sessionAuditCount(t, s, tenant, 8) - var owners, creates int - if err := s.pool.QueryRow(t.Context(), `SELECT count(*),count(DISTINCT operation_id) FROM write_audit_owners WHERE tenant_id=$1`, tenant).Scan(&owners, &creates); err != nil || owners != 2 || creates != 1 { - t.Fatal("creation race attribution", owners, creates, err) - } -} - -func TestSessionWriteAuditRollback(t *testing.T) { - for _, operation := range []string{"create", "update", "delete", "events", "noop", "reserve"} { - t.Run(operation, func(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := environmentInput("audit-rollback", "self_hosted", "/workspace") - ctx := sessionAuditContext(t, tenant, "a") - rejectSessionAudit(t, s, ctx) - if operation == "create" { - if _, err := s.CreateSession(ctx, tenant, input); err == nil { - t.Fatal("creation bypassed audit failure") - } - var count int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { - t.Fatal("creation did not roll back", count, err) - } - return - } - created, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - switch operation { - case "update": - _, err = s.UpdateSessionMetadata(ctx, tenant, created.ID, map[string]string{"new": "value"}) - case "delete": - err = s.DeleteSession(ctx, tenant, created.ID) - case "events": - _, err = s.SubmitInputs(ctx, tenant, created.ID, "events", []Input{messageInput("private")}) - case "noop": - err = s.AuditSessionOperation(ctx, tenant, created.ID, "send_events") - case "reserve": - _, err = s.ReserveEnvironmentInput(ctx, tenant, created.ID, "reserve", []Input{messageInput("private")}) - } - if err == nil { - t.Fatal("mutation bypassed audit failure") - } - got, err := s.GetSession(t.Context(), tenant, created.ID) - if err != nil || len(got.Metadata) != 0 { - t.Fatal("resource update/deletion survived rollback", got, err) - } - var inputs, reservations int - if err := s.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM turn_inputs WHERE session_id=$1),(SELECT count(*) FROM environment_input_reservations WHERE session_id=$1)`, created.ID).Scan(&inputs, &reservations); err != nil || inputs != 0 || reservations != 0 { - t.Fatal("input survived rollback", inputs, reservations, err) - } - sessionAuditCount(t, s, tenant, 0) - }) - } -} - -func TestEventsWriteAuditAdmissionAndReplay(t *testing.T) { - for _, prepared := range []bool{false, true} { - t.Run(fmt.Sprint(prepared), func(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := environmentInput("audit-events", "self_hosted", "/workspace") - created, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - submit := func(ctx context.Context) error { - if prepared { - _, err := s.ReserveEnvironmentInput(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) - return err - } - _, err := s.SubmitInputs(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) - return err - } - first := sessionAuditContext(t, tenant, "a") - for _, ctx := range []context.Context{first, first, sessionAuditContext(t, tenant, "b")} { - if err := submit(ctx); err != nil { - t.Fatal(err) - } - } - sessionAuditCount(t, s, tenant, 2) - var owners int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1", tenant).Scan(&owners); err != nil || owners != 0 { - t.Fatal("events acquired historical ownership", owners, err) - } - }) - } -} - -func TestSessionWriteAuditInitialInputAndHistoricalReplay(t *testing.T) { - for _, kind := range []string{"none", "self_hosted"} { - t.Run(kind, func(t *testing.T) { - s, _ := testStore(t) - tenant := uuid.NewString() - input := environmentInput("audit-initial", kind, "/workspace") - input.InitialInputs = []Input{messageInput("private initial message")} - created, err := s.CreateSessionStream(sessionAuditContext(t, tenant, "a"), tenant, input) - if err != nil || !created.Created { - t.Fatal(created, err) - } - sessionAuditCount(t, s, tenant, 1) - input.IdempotencyKey = "legacy" - legacy, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, legacy.ID, "create"); err != nil { - t.Fatal(err) - } - var owners int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_id=$2", tenant, legacy.ID).Scan(&owners); err != nil || owners != 0 { - t.Fatal("replay attributed historical resource", owners, err) - } - sessionAuditCount(t, s, tenant, 2) - }) - } -} diff --git a/services/agents-api/internal/store/skills.go b/services/agents-api/internal/store/skills.go deleted file mode 100644 index 1507f20fd..000000000 --- a/services/agents-api/internal/store/skills.go +++ /dev/null @@ -1,194 +0,0 @@ -package store - -import ( - "context" - "errors" - "strconv" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type Skill struct { - ID string - Name string - Description string - CreatedAt time.Time - DefaultVersion int64 - LatestVersion int64 -} - -type SkillVersion struct { - ID string - SkillID string - Version int64 - Name string - Description string - CreatedAt time.Time -} - -func (s *Store) CreateSkill(ctx context.Context, tenantID string, archive []byte) (Skill, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Skill{}, err - } - metadata, err := agentskill.Inspect(archive) - if err != nil { - return Skill{}, ErrInvalidInput - } - var result Skill - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - id := pgtype.UUID{Bytes: uuid.New(), Valid: true} - row, err := q.CreateSkill(ctx, sqlc.CreateSkillParams{ID: id, TenantID: tenant, Name: metadata.Name, Description: metadata.Description}) - if err != nil { - return err - } - initial, err := s.saveSkillVersion(ctx, q, tenant, id, 1, metadata, archive) - if err != nil { - return err - } - result = skillFromRow(row) - return recordWriteAudit(ctx, q, tenantID, "create", "skill", result.ID, "", - AuditResource{Type: "skill", ID: result.ID}, - AuditResource{Type: "skill_version", ID: initial.ID, ParentID: result.ID}) - }) - return result, err -} - -func (s *Store) GetSkill(ctx context.Context, tenantID, skillID string) (Skill, error) { - tenant, id, err := skillPathIDs(tenantID, skillID) - if err != nil { - return Skill{}, err - } - row, err := s.queries.GetSkill(ctx, sqlc.GetSkillParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return Skill{}, ErrNotFound - } - return skillFromRow(row), err -} - -func (s *Store) UpdateSkillDefault(ctx context.Context, tenantID, skillID, version string) (Skill, error) { - tenant, id, err := skillPathIDs(tenantID, skillID) - if err != nil { - return Skill{}, err - } - number, err := skillVersionNumber(version) - if err != nil { - return Skill{}, err - } - var result Skill - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - if _, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: id}); err != nil { - return err - } - version, err := q.GetSkillVersion(ctx, sqlc.GetSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) - if err != nil { - return err - } - row, err := q.SetDefaultSkillVersion(ctx, sqlc.SetDefaultSkillVersionParams{TenantID: tenant, ID: id, DefaultVersion: number, Name: version.Name, Description: version.Description}) - if err != nil { - return err - } - result = skillFromRow(row) - return recordWriteAudit(ctx, q, tenantID, "update_default_version", "skill", result.ID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - err = ErrNotFound - } - return result, err -} - -func (s *Store) DeleteSkill(ctx context.Context, tenantID, skillID string) error { - tenant, id, err := skillPathIDs(tenantID, skillID) - if err != nil { - return err - } - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - if _, err := q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}); err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "delete", "skill", skillID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - return err -} - -func (s *Store) saveSkillVersion(ctx context.Context, q *sqlc.Queries, tenant, skill pgtype.UUID, version int64, metadata agentskill.Metadata, archive []byte) (SkillVersion, error) { - id := pgtype.UUID{Bytes: uuid.New(), Valid: true} - body, err := s.credentialCipher.SealSkill(archive, skillBinding(tenant, skill, id, version)) - if err != nil { - return SkillVersion{}, err - } - row, err := q.CreateSkillVersion(ctx, sqlc.CreateSkillVersionParams{ID: id, TenantID: tenant, SkillID: skill, Version: version, Name: metadata.Name, Description: metadata.Description, Contents: body}) - return skillVersionFromRow(sqlc.GetSkillVersionRow(row)), err -} - -func skillBinding(tenant, skill, versionID pgtype.UUID, version int64) credentialcrypto.SkillBinding { - return credentialcrypto.SkillBinding{TenantID: uuid.UUID(tenant.Bytes).String(), SkillID: uuid.UUID(skill.Bytes).String(), VersionID: uuid.UUID(versionID.Bytes).String(), Version: strconv.FormatInt(version, 10)} -} - -func skillIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { - tenant, err := parseID(tenantID) - if err != nil { - return tenant, pgtype.UUID{}, err - } - id, err := skillResourceID(skillID, "skill_") - return tenant, id, err -} - -func skillResourceID(value, prefix string) (pgtype.UUID, error) { - id, err := uuid.Parse(strings.TrimPrefix(value, prefix)) - if err != nil || id == uuid.Nil || value != prefix+id.String() { - return pgtype.UUID{}, ErrNotFound - } - return pgtype.UUID{Bytes: id, Valid: true}, nil -} - -func skillVersionNumber(value string) (int64, error) { - number, err := strconv.ParseInt(value, 10, 64) - if err != nil || number < 1 || strconv.FormatInt(number, 10) != value { - return 0, ErrInvalidInput - } - return number, nil -} - -// skillPathIDs resolves a Skill path identifier. Like parsePathID, a malformed -// value resolves to an identifier that never exists, so the request follows the -// missing-Skill path. Request-body references keep skillIDs. -func skillPathIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { - tenant, id, err := skillIDs(tenantID, skillID) - if errors.Is(err, ErrNotFound) { - return tenant, pgtype.UUID{Bytes: uuid.Max, Valid: true}, nil - } - return tenant, id, err -} - -// skillPathVersion resolves a version path segment. Versions start at 1, so a -// malformed segment resolves to the never-assigned version 0 and follows the -// missing-version path. Request-body selectors keep skillVersionNumber. -func skillPathVersion(value string) int64 { - number, err := skillVersionNumber(value) - if err != nil { - return 0 - } - return number -} - -func skillFromRow(row sqlc.Skill) Skill { - return Skill{ID: "skill_" + uuid.UUID(row.ID.Bytes).String(), Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time, DefaultVersion: row.DefaultVersion, LatestVersion: row.LatestVersion} -} - -func skillVersionFromRow(row sqlc.GetSkillVersionRow) SkillVersion { - return SkillVersion{ID: "skillver_" + uuid.UUID(row.ID.Bytes).String(), SkillID: "skill_" + uuid.UUID(row.SkillID.Bytes).String(), Version: row.Version, Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time} -} diff --git a/services/agents-api/internal/store/skills_list.go b/services/agents-api/internal/store/skills_list.go deleted file mode 100644 index 782da1803..000000000 --- a/services/agents-api/internal/store/skills_list.go +++ /dev/null @@ -1,107 +0,0 @@ -package store - -import ( - "context" - "errors" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type SkillPage struct { - Skills []Skill - HasMore bool -} - -type SkillVersionPage struct { - Versions []SkillVersion - HasMore bool -} - -// ListSkills and ListSkillVersions accept limit 0: the page is empty and HasMore -// reports whether any resource follows the cursor. -func (s *Store) ListSkills(ctx context.Context, tenantID, after string, limit int, ascending bool) (SkillPage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return SkillPage{}, err - } - if limit < 0 || limit > 100 { - return SkillPage{}, ErrInvalidInput - } - params := sqlc.ListSkillsParams{TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} - if after != "" { - cursor, err := s.GetSkill(ctx, tenantID, after) - if err != nil { - return SkillPage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: cursor.CreatedAt, Valid: true} - params.AfterID, _ = skillResourceID(cursor.ID, "skill_") - } - rows, err := s.queries.ListSkills(ctx, params) - if err != nil { - return SkillPage{}, err - } - page := SkillPage{Skills: make([]Skill, 0, min(limit, len(rows))), HasMore: len(rows) > limit} - if page.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - page.Skills = append(page.Skills, skillFromRow(row)) - } - return page, nil -} - -func (s *Store) ListSkillVersions(ctx context.Context, tenantID, skillID, after string, limit int, ascending bool) (SkillVersionPage, error) { - tenant, id, err := skillPathIDs(tenantID, skillID) - if err != nil { - return SkillVersionPage{}, err - } - if limit < 0 || limit > 100 { - return SkillVersionPage{}, ErrInvalidInput - } - if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { - return SkillVersionPage{}, err - } - params := sqlc.ListSkillVersionsParams{TenantID: tenant, SkillID: id, PageLimit: int32(limit + 1), Ascending: ascending} - if after != "" { - // A value that is not a version resource ID is invalid; a well-formed - // version missing from this tenant, including another tenant's, is not - // found; another Skill's version in this tenant does not match. - if !strings.HasPrefix(after, "skillver") { - return SkillVersionPage{}, skillVersionCursorPrefix(after) - } - cursorID, err := skillResourceID(after, "skillver_") - if err != nil { - return SkillVersionPage{}, err - } - cursor, err := s.queries.GetSkillVersionByID(ctx, sqlc.GetSkillVersionByIDParams{TenantID: tenant, ID: cursorID}) - if errors.Is(err, pgx.ErrNoRows) { - return SkillVersionPage{}, ErrNotFound - } - if err != nil { - return SkillVersionPage{}, err - } - if cursor.SkillID != id { - // As for Artifacts, a Skill deleted since its lookup stays not found. - if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { - return SkillVersionPage{}, err - } - return SkillVersionPage{}, errSkillVersionCursorParent - } - params.AfterVersion = pgtype.Int8{Int64: cursor.Version, Valid: true} - } - rows, err := s.queries.ListSkillVersions(ctx, params) - if err != nil { - return SkillVersionPage{}, err - } - page := SkillVersionPage{Versions: make([]SkillVersion, 0, min(limit, len(rows))), HasMore: len(rows) > limit} - if page.HasMore { - rows = rows[:limit] - } - for _, row := range rows { - page.Versions = append(page.Versions, skillVersionFromRow(sqlc.GetSkillVersionRow(row))) - } - return page, nil -} diff --git a/services/agents-api/internal/store/skills_test.go b/services/agents-api/internal/store/skills_test.go deleted file mode 100644 index 81122740d..000000000 --- a/services/agents-api/internal/store/skills_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package store - -import ( - "archive/zip" - "bytes" - "errors" - "fmt" - "sort" - "strconv" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func skillArchive(t *testing.T, marker string) []byte { - t.Helper() - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - file, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store}) - if err != nil { - t.Fatal(err) - } - if _, err = fmt.Fprintf(file, "---\nname: proof\ndescription: Verify a versioned Skill.\n---\n%s", marker); err != nil { - t.Fatal(err) - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - return buffer.Bytes() -} - -func TestSkillsOwnershipEncryptionAndVersions(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - tenant, foreign := uuid.NewString(), uuid.NewString() - archive := skillArchive(t, "confidential-skill-canary") - created, err := s.CreateSkill(t.Context(), tenant, archive) - if err != nil { - t.Fatal(err) - } - if created.DefaultVersion != 1 || created.LatestVersion != 1 { - t.Fatal("initial pointers", created) - } - t.Cleanup(func() { _ = s.DeleteSkill(t.Context(), tenant, created.ID) }) - metadata, err := New(pool).GetSkill(t.Context(), tenant, created.ID) - if err != nil || metadata.Name != "proof" { - t.Fatal("metadata requires no content key", err) - } - var contents []byte - if err = pool.QueryRow(t.Context(), "SELECT contents FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&contents); err != nil { - t.Fatal(err) - } - if bytes.Contains(contents, []byte("confidential-skill-canary")) { - t.Fatal("plaintext bundle persisted") - } - version, body, err := s.ReadSkillVersion(t.Context(), tenant, created.ID, "1") - if err != nil || !bytes.Equal(body, archive) || version.Version != 1 { - t.Fatal("content round trip", err) - } - if _, err = s.GetSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign metadata", err) - } - if _, _, err = s.ReadSkillVersion(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign content", err) - } - if _, err = s.CreateSkillVersion(t.Context(), foreign, created.ID, archive, true); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign version", err) - } - if _, err = s.UpdateSkillDefault(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign pointer", err) - } - if err = s.DeleteSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign delete", err) - } - if _, err = s.ListSkills(t.Context(), foreign, created.ID, 20, false); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign cursor", err) - } - if _, err = s.ListSkillVersions(t.Context(), foreign, created.ID, "", 20, false); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign versions", err) - } - - const count = 8 - results := make(chan SkillVersion, count) - failures := make(chan error, count) - var group sync.WaitGroup - for range count { - group.Add(1) - go func() { - defer group.Done() - version, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, false) - if err != nil { - failures <- err - } else { - results <- version - } - }() - } - group.Wait() - close(results) - close(failures) - for err := range failures { - t.Fatal(err) - } - numbers := []int{} - for version := range results { - numbers = append(numbers, int(version.Version)) - } - sort.Ints(numbers) - for i, number := range numbers { - if number != i+2 { - t.Fatal("concurrent version allocation", numbers) - } - } - if len(numbers) != count { - t.Fatal("missing versions", numbers) - } - current, err := s.GetSkill(t.Context(), tenant, created.ID) - if err != nil || current.DefaultVersion != 1 || current.LatestVersion != count+1 { - t.Fatal("concurrent pointers", current, err) - } - first, err := s.ListSkillVersions(t.Context(), tenant, created.ID, "", 3, true) - if err != nil || !first.HasMore || len(first.Versions) != 3 || first.Versions[0].Version != 1 { - t.Fatal("first page", first, err) - } - next, err := s.ListSkillVersions(t.Context(), tenant, created.ID, first.Versions[2].ID, 20, true) - if err != nil || next.HasMore || len(next.Versions) != 6 || next.Versions[0].Version != 4 { - t.Fatal("version resource cursor", next, err) - } - var cursorErr *InvalidCursorError - if _, err = s.ListSkillVersions(t.Context(), tenant, created.ID, "3", 20, true); !errors.As(err, &cursorErr) || cursorErr.Message != "Invalid 'after': '3'. Expected an ID that begins with 'skillver'." { - t.Fatal("numeric version is not a cursor", err) - } - if _, err = s.UpdateSkillDefault(t.Context(), tenant, created.ID, "999"); !errors.Is(err, ErrNotFound) { - t.Fatal("missing default", err) - } - updated, err := s.UpdateSkillDefault(t.Context(), tenant, created.ID, "3") - if err != nil || updated.DefaultVersion != 3 { - t.Fatal("default update", err) - } - if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, "3"); !errors.Is(err, ErrDefaultSkillVersion) { - t.Fatal("default deletion", err) - } - if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, strconv.Itoa(count+1)); err != nil { - t.Fatal(err) - } - added, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, true) - if err != nil || added.Version != count+2 { - t.Fatal("deleted version number reused", added, err) - } - if err = s.DeleteSkill(t.Context(), tenant, created.ID); err != nil { - t.Fatal(err) - } - if _, _, err = s.ReadSkillVersion(t.Context(), tenant, created.ID, "1"); !errors.Is(err, ErrNotFound) { - t.Fatal("cascaded content", err) - } - var remaining int - if err = pool.QueryRow(t.Context(), "SELECT count(*) FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&remaining); err != nil || remaining != 0 { - t.Fatal("orphan content", remaining, err) - } -} diff --git a/services/agents-api/internal/store/source_files.go b/services/agents-api/internal/store/source_files.go deleted file mode 100644 index d9f2b95eb..000000000 --- a/services/agents-api/internal/store/source_files.go +++ /dev/null @@ -1,235 +0,0 @@ -package store - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "io" - "strings" - "time" - "unicode/utf8" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -const MaxSourceFileBytes int64 = 512 << 20 - -var ErrSourceFileTooLarge = errors.New("source file exceeds storage limit") - -type SourceFile struct { - ID string - Filename string - Purpose string - SizeBytes int64 - CreatedAt time.Time -} - -type SourceFileUpload struct { - Filename string - Purpose string -} - -type SourceFilePage struct { - Files []SourceFile - NextCursor string -} - -// CreateSourceFile commits only after the complete upload envelope has validated. -func (s *Store) CreateSourceFile(ctx context.Context, tenantID string, upload func(io.Writer) (SourceFileUpload, error)) (SourceFile, error) { - tenant, err := parseID(tenantID) - if err != nil || upload == nil { - return SourceFile{}, ErrInvalidInput - } - tx, err := s.pool.Begin(ctx) - if err != nil { - return SourceFile{}, err - } - defer tx.Rollback(context.Background()) - objects := tx.LargeObjects() - oid, err := objects.Create(ctx, 0) - if err != nil { - return SourceFile{}, err - } - body, err := objects.Open(ctx, oid, pgx.LargeObjectModeWrite) - if err != nil { - return SourceFile{}, err - } - writer := newSourceFileWriter(body) - input, err := upload(writer) - if writer.err != nil { - return SourceFile{}, writer.err - } - if err != nil { - return SourceFile{}, err - } - if !validSourceFilename(input.Filename) || input.Purpose != "user_data" { - return SourceFile{}, ErrInvalidInput - } - if err := body.Close(); err != nil { - return SourceFile{}, err - } - row, err := s.queries.WithTx(tx).CreateSourceFile(ctx, sqlc.CreateSourceFileParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Filename: input.Filename, Purpose: input.Purpose, BodyOid: pgtype.Uint32{Uint32: oid, Valid: true}, - SizeBytes: writer.size, Sha256: hex.EncodeToString(writer.hash.Sum(nil)), - }) - if err != nil { - return SourceFile{}, fmt.Errorf("create source file: %w", err) - } - resource := sourceFileFromRow(row) - if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "create", "file", resource.ID, "", AuditResource{Type: "file", ID: resource.ID}); err != nil { - return SourceFile{}, err - } - if err := tx.Commit(ctx); err != nil { - return SourceFile{}, err - } - return sourceFileFromRow(row), nil -} - -func (s *Store) GetSourceFile(ctx context.Context, tenantID, fileID string) (SourceFile, error) { - tenant, id, err := sourceFileIDs(tenantID, fileID) - if err != nil { - return SourceFile{}, err - } - row, err := s.queries.GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return SourceFile{}, ErrNotFound - } - if err != nil { - return SourceFile{}, err - } - return sourceFileFromRow(row), nil -} - -func (s *Store) ListSourceFiles(ctx context.Context, tenantID, cursor string, limit int, ascending bool, purpose *string) (SourceFilePage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return SourceFilePage{}, err - } - if limit < 1 || limit > 10000 { - return SourceFilePage{}, fmt.Errorf("%w: internal page size must be 1..10000", ErrInvalidInput) - } - params := sqlc.ListSourceFilesParams{ - TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, - AfterID: pgtype.UUID{Valid: true}, - } - if purpose != nil { - if !utf8.ValidString(*purpose) || strings.ContainsRune(*purpose, '\x00') { - return SourceFilePage{}, ErrInvalidInput - } - params.Purpose = pgtype.Text{String: *purpose, Valid: true} - } - if cursor != "" { - after, err := s.GetSourceFile(ctx, tenantID, cursor) - if err != nil { - return SourceFilePage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - _, params.AfterID, _ = sourceFileIDs(tenantID, after.ID) - } - rows, err := s.queries.ListSourceFiles(ctx, params) - if err != nil { - return SourceFilePage{}, fmt.Errorf("list source files: %w", err) - } - page := SourceFilePage{Files: make([]SourceFile, 0, min(limit, len(rows)))} - if len(rows) > limit { - page.NextCursor = sourceFileFromRow(rows[limit-1]).ID - rows = rows[:limit] - } - for _, row := range rows { - page.Files = append(page.Files, sourceFileFromRow(row)) - } - return page, nil -} - -// ReadSourceFile retains an authorized immutable snapshot during concurrent deletion. -func (s *Store) ReadSourceFile(ctx context.Context, tenantID, fileID string, consume func(SourceFile, io.Reader) error) error { - tenant, id, err := sourceFileIDs(tenantID, fileID) - if err != nil { - return err - } - if consume == nil { - return ErrInvalidInput - } - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) - if err != nil { - return err - } - defer tx.Rollback(context.Background()) - row, err := s.queries.WithTx(tx).GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if err := consumeSourceFile(ctx, tx, row, consume); err != nil { - return err - } - return tx.Commit(ctx) -} - -func consumeSourceFile(ctx context.Context, tx pgx.Tx, row sqlc.SourceFile, consume func(SourceFile, io.Reader) error) error { - objects := tx.LargeObjects() - body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) - if err != nil { - return err - } - if err := consume(sourceFileFromRow(row), body); err != nil { - return err - } - return body.Close() -} - -func (s *Store) DeleteSourceFile(ctx context.Context, tenantID, fileID string) error { - tenant, id, err := sourceFileIDs(tenantID, fileID) - if err != nil { - return err - } - tx, err := s.pool.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(context.Background()) - oid, err := s.queries.WithTx(tx).DeleteSourceFile(ctx, sqlc.DeleteSourceFileParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - objects := tx.LargeObjects() - if err := objects.Unlink(ctx, oid.Uint32); err != nil { - return err - } - if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "delete", "file", fileID, ""); err != nil { - return err - } - return tx.Commit(ctx) -} - -func sourceFileIDs(tenantID, fileID string) (pgtype.UUID, pgtype.UUID, error) { - tenant, err := parseID(tenantID) - if err != nil { - return tenant, pgtype.UUID{}, err - } - id, err := uuid.Parse(strings.TrimPrefix(fileID, "file-")) - if err != nil || id == uuid.Nil || fileID != "file-"+id.String() { - return tenant, pgtype.UUID{}, ErrNotFound - } - return tenant, pgtype.UUID{Bytes: id, Valid: true}, nil -} - -func validSourceFilename(name string) bool { - return len(name) >= 1 && len(name) <= 1024 && utf8.ValidString(name) && !strings.ContainsRune(name, '\x00') -} - -func sourceFileFromRow(row sqlc.SourceFile) SourceFile { - return SourceFile{ID: "file-" + uuid.UUID(row.ID.Bytes).String(), Filename: row.Filename, - Purpose: row.Purpose, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} -} diff --git a/services/agents-api/internal/store/subagent_coordination.go b/services/agents-api/internal/store/subagent_coordination.go deleted file mode 100644 index d845c5a1e..000000000 --- a/services/agents-api/internal/store/subagent_coordination.go +++ /dev/null @@ -1,110 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func coordinationItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentCoordinationPayload) (v1.Item, error) { - value := v1.Item{ID: items.Identity(turn, "coordination:"+p.ID), TurnID: turn, Type: p.Kind, Status: p.Status, Model: p.Model, ReasoningEffort: p.ReasoningEffort} - if !validNativeIdentity(p.ID) { - return value, ErrInvalidInput - } - if p.Status != "in_progress" && p.Status != "completed" && p.Status != "failed" && p.Status != "incomplete" && p.Kind != "agent_message" { - return value, ErrInvalidInput - } - resolve := func(native string, failedReference bool) (string, error) { - if native == "" { - return q.SubagentRootAgent(ctx, session) - } - row, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: native}) - if errors.Is(err, pgx.ErrNoRows) && failedReference && validNativeIdentity(native) { - return native, nil - } - if err != nil { - return "", err - } - if !row.PublicVisible { - return "", ErrNotFound - } - return uuid.UUID(row.ID.Bytes).String(), nil - } - actor, err := resolve(p.ActorID, false) - if err != nil { - return value, err - } - if actor == "" { - return value, ErrInvalidInput - } - value.SenderAgentID = actor - if p.Text != nil { - value.Content = []v1.ItemContent{{Type: "output_text", Text: p.Text}} - } - switch p.Kind { - case "create_subagent_call": - value.AgentID = actor - case "wait_for_subagents_call": - value.RecipientAgentIDs = []string{} - for _, recipient := range p.Recipients { - id, err := resolve(recipient, p.Status == "failed") - if err != nil { - return value, err - } - value.RecipientAgentIDs = append(value.RecipientAgentIDs, id) - } - case "send_subagent_input_call", "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call", "agent_message": - if len(p.Recipients) != 1 { - return value, ErrInvalidInput - } - value.RecipientAgentID, err = resolve(p.Recipients[0], p.Status == "failed") - if err != nil { - return value, err - } - default: - return value, ErrInvalidInput - } - return value, nil -} -func projectRootCoordination(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, raw json.RawMessage, created pgtype.Timestamptz) error { - var p proto.SubagentCoordinationPayload - if json.Unmarshal(raw, &p) != nil || p.ActorID != "" { - return ErrInvalidInput - } - value, err := coordinationItem(ctx, q, session, uuid.UUID(turn.Bytes).String(), p) - if err != nil { - return err - } - id, _ := parseID(value.ID) - old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - var previous v1.Item - if err == nil { - if err = json.Unmarshal(old.Payload, &previous); err != nil { - return err - } - } - merged, err := items.Merge(items.Update{Item: value}, previous) - if err != nil { - return err - } - payload, err := merged.MarshalStored() - if err != nil { - return err - } - row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: true}) - if err != nil { - return err - } - return recordItemChange(ctx, q, session, row.OutputIndex, previous, merged, nil) -} diff --git a/services/agents-api/internal/store/turns.go b/services/agents-api/internal/store/turns.go deleted file mode 100644 index 51832f6e2..000000000 --- a/services/agents-api/internal/store/turns.go +++ /dev/null @@ -1,176 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -var ErrTurnConflict = errors.New("turn state changed or cancellation was requested") - -const ( - TurnQueued = "queued" - TurnInProgress = "in_progress" - TurnWaiting = "waiting" - TurnCompleted = "completed" - TurnFailed = "failed" - TurnCancelled = "cancelled" -) - -// Turn is a root Turn from the Core work queue and uses its Session's immutable -// execution configuration; Subagent Turns have a native writer and their own -// table. Zero timestamps mean the corresponding event has not occurred. Outcome -// is adapter-owned data, not an upstream response; the API must project -// supported wire types explicitly. -type Turn struct { - ID, SessionID, Status string - CreatedAt time.Time - StartedAt time.Time - CompletedAt time.Time - CancelRequestedAt time.Time - Usage json.RawMessage - Outcome json.RawMessage - // ArtifactCaptureStarted is private Runtime coordination, never a wire field. - ArtifactCaptureStarted bool `json:"-"` -} - -type TurnTransition struct { - ExpectedStatus string - Status string - Outcome json.RawMessage -} - -// GetTurn reads a root Turn. A Subagent Turn ID is not found here, exactly like -// a missing one; GetSubagentTurn reads child Turns. -func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (Turn, error) { - params, err := publicTurnLookup(tenantID, sessionID, turnID) - if err != nil { - return Turn{}, err - } - row, err := s.queries.GetTurn(ctx, params) - if errors.Is(err, pgx.ErrNoRows) { - return Turn{}, ErrNotFound - } - if err != nil { - return Turn{}, fmt.Errorf("get turn: %w", err) - } - return turnFromRow(row), nil -} - -// TransitionTurn is a compare-and-set for execution callbacks. Once terminal, -// a Turn cannot be reopened or have its outcome overwritten, including by retries. -// A dispatcher must claim queued -> in_progress before sending work to a daemon. -func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID string, input TurnTransition) (Turn, error) { - params, err := turnLookup(tenantID, sessionID, turnID) - if err != nil { - return Turn{}, err - } - if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { - return Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) - } - outcome, err := canonicalJSONObject(input.Outcome) - if err != nil { - return Turn{}, err - } - if !terminalStatus(input.Status) && string(outcome) != "{}" { - return Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) - } - input.Outcome = outcome - var row sqlc.Turn - err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, _ pgtype.UUID) error { - var err error - row, err = transitionTurn(ctx, q, params, input) - return err - }) - if err != nil { - return Turn{}, fmt.Errorf("transition turn: %w", err) - } - return turnFromRow(row), nil -} - -func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnParams, input TurnTransition) (sqlc.Turn, error) { - if _, err := q.GetTurn(ctx, params); errors.Is(err, pgx.ErrNoRows) { - return sqlc.Turn{}, ErrNotFound - } else if err != nil { - return sqlc.Turn{}, err - } - if input.ExpectedStatus == TurnQueued && input.Status == TurnInProgress { - if err := checkRuntimeComputeAdmission(ctx, q, params.SessionID); err != nil { - return sqlc.Turn{}, err - } - } - row, err := q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ - ID: params.ID, SessionID: params.SessionID, ExpectedStatus: input.ExpectedStatus, - NewStatus: input.Status, Outcome: input.Outcome, - }) - if errors.Is(err, pgx.ErrNoRows) { - return sqlc.Turn{}, ErrTurnConflict - } - if err == nil && terminalStatus(row.Status) { - if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { - return sqlc.Turn{}, err - } - row, err = q.GetTurn(ctx, params) - } - if err != nil { - return sqlc.Turn{}, err - } - if err := recordTurnChange(ctx, q, row, false); err != nil { - return sqlc.Turn{}, err - } - return row, nil -} - -func validTransition(from, to string) bool { - switch from { - case TurnQueued: - return to == TurnInProgress || to == TurnFailed || to == TurnCancelled - case TurnInProgress: - return to == TurnWaiting || terminalStatus(to) - case TurnWaiting: - return to == TurnInProgress || terminalStatus(to) - default: - return false - } -} - -func terminalStatus(status string) bool { - return status == TurnCompleted || status == TurnFailed || status == TurnCancelled -} - -func turnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { - var p sqlc.GetTurnParams - var err error - if p.TenantID, err = parseID(tenantID); err != nil { - return p, err - } - if p.SessionID, err = parseID(sessionID); err != nil { - return p, err - } - p.ID, err = parseID(turnID) - return p, err -} - -// publicTurnLookup resolves caller-supplied path identifiers for a Turn or a -// Turn-scoped resource. Unparsable values are indistinguishable from missing ones. -func publicTurnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { - tenant, err := parseID(tenantID) - return sqlc.GetTurnParams{TenantID: tenant, SessionID: parsePathID(sessionID), ID: parsePathID(turnID)}, err -} - -func turnFromRow(row sqlc.Turn) Turn { - return Turn{ - ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), Status: row.Status, - CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, - CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: json.RawMessage(row.Outcome), Usage: json.RawMessage(row.TokenUsage), - ArtifactCaptureStarted: row.ArtifactCaptureStarted, - } -} diff --git a/services/agents-api/internal/store/vaults.go b/services/agents-api/internal/store/vaults.go deleted file mode 100644 index 388b3eb64..000000000 --- a/services/agents-api/internal/store/vaults.go +++ /dev/null @@ -1,108 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - "unicode/utf8" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -// Vault is a tenant-owned resource, independent of Sessions and engine execution. -type Vault struct { - ID string - TenantID string - Name *string - Metadata map[string]string - CreatedAt time.Time -} - -type CreateVaultInput struct { - Name *string - Metadata map[string]string -} - -// CreateVault persists the public layer's normalized name. Each call creates a -// distinct resource; this primitive does not define create retry semantics. -func (s *Store) CreateVault(ctx context.Context, tenantID string, input CreateVaultInput) (Vault, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Vault{}, err - } - var name pgtype.Text - if input.Name != nil { - if !validVaultName(*input.Name) { - return Vault{}, fmt.Errorf("%w: vault name must contain 1–256 UTF-8 bytes", ErrInvalidInput) - } - name = pgtype.Text{String: *input.Name, Valid: true} - } - metadata, err := encodeMetadata(input.Metadata) - if err != nil { - return Vault{}, err - } - var created Vault - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.CreateVault(ctx, sqlc.CreateVaultParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Name: name, Metadata: metadata, - }) - if err != nil { - return err - } - created, err = vaultFromRow(row) - if err != nil { - return err - } - return recordWriteAudit(ctx, q, tenantID, "create", "vault", created.ID, "", AuditResource{Type: "vault", ID: created.ID, ParentID: ""}) - }) - if err != nil { - return Vault{}, fmt.Errorf("create vault: %w", err) - } - return created, nil -} - -func validVaultName(name string) bool { - return len(name) >= 1 && len(name) <= 256 && utf8.ValidString(name) -} - -// GetVault scopes every lookup to the authenticated caller's tenant. -func (s *Store) GetVault(ctx context.Context, tenantID, vaultID string) (Vault, error) { - tenant, err := parseID(tenantID) - if err != nil { - return Vault{}, err - } - id, err := parseID(vaultID) - if err != nil { - return Vault{}, err - } - row, err := s.queries.GetVault(ctx, sqlc.GetVaultParams{TenantID: tenant, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return Vault{}, ErrNotFound - } - if err != nil { - return Vault{}, fmt.Errorf("get vault: %w", err) - } - return vaultFromRow(row) -} - -func vaultFromRow(row sqlc.Vault) (Vault, error) { - vault := Vault{ - ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), - CreatedAt: row.CreatedAt.Time, - } - if row.Name.Valid { - vault.Name = &row.Name.String - } - if err := json.Unmarshal(row.Metadata, &vault.Metadata); err != nil { - return Vault{}, fmt.Errorf("decode vault metadata: %w", err) - } - return vault, nil -} diff --git a/services/agents-api/internal/store/vaults_delete.go b/services/agents-api/internal/store/vaults_delete.go deleted file mode 100644 index 9b92a7cf7..000000000 --- a/services/agents-api/internal/store/vaults_delete.go +++ /dev/null @@ -1,39 +0,0 @@ -package store - -import ( - "context" - "errors" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// DeleteVault relies on the owning foreign key to remove every stored Credential. -func (s *Store) DeleteVault(ctx context.Context, tenantID, vaultID string) (string, error) { - tenant, err := parseID(tenantID) - if err != nil { - return "", ErrNotFound - } - id, err := parseID(vaultID) - if err != nil { - return "", ErrNotFound - } - var deletedID string - err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - deleted, err := q.DeleteVault(ctx, sqlc.DeleteVaultParams{TenantID: tenant, ID: id}) - if err != nil { - return err - } - deletedID = uuid.UUID(deleted.Bytes).String() - return recordWriteAudit(ctx, q, tenantID, "delete", "vault", deletedID, "") - }) - if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound - } - if err != nil { - return "", errors.New("vault deletion failed") - } - return deletedID, nil -} diff --git a/services/agents-api/internal/store/vaults_delete_test.go b/services/agents-api/internal/store/vaults_delete_test.go deleted file mode 100644 index b4b213f65..000000000 --- a/services/agents-api/internal/store/vaults_delete_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" -) - -func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { - public, pool := testStore(t) - tenant, foreign := uuid.NewString(), uuid.NewString() - key := bytes.Repeat([]byte{43}, 32) - cipher, err := credentialcrypto.New(key) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - createVault := func() Vault { - t.Helper() - v, err := public.CreateVault(t.Context(), tenant, CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - return v - } - vault, retained, empty := createVault(), createVault(), createVault() - create := func(id, name, url string) Credential { - t.Helper() - v, err := s.CreateStaticCredential(t.Context(), tenant, id, CreateStaticCredentialInput{Name: name, MCPServerURL: url, Token: name + "-secret"}) - if err != nil { - t.Fatal(err) - } - return v - } - original := create(vault.ID, "original", "https://mcp.example/tools") - attached := []string{vault.ID, retained.ID} - selected, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL}}) - if err != nil || len(selected) != 1 { - t.Fatal("initial unique selection failed", err) - } - configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "model"}, "vault_ids": attached, "mcp_credentials": selected}) - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "retained", Configuration: configuration} - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - extra := create(vault.ID, "archived", "https://mcp.example/other") - sibling := create(retained.ID, "sibling", original.MCPServerURL) - if _, err := pool.Exec(t.Context(), "UPDATE vaults SET status='archived' WHERE id=$1", vault.ID); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET status='archived' WHERE id=$1", extra.ID); err != nil { - t.Fatal(err) - } - for _, scope := range []struct{ tenant, id string }{{foreign, vault.ID}, {tenant, uuid.NewString()}, {tenant, "invalid"}, {"invalid", vault.ID}} { - if _, err := public.DeleteVault(t.Context(), scope.tenant, scope.id); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign or invalid deletion was accepted", err) - } - } - readOnly := readOnlyResourceStore(t, pool) - _, deletionErr := readOnly.DeleteVault(t.Context(), tenant, vault.ID) - if deletionErr == nil || deletionErr.Error() != "vault deletion failed" { - t.Fatal("failed mutation was accepted or exposed") - } - tx, err := pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer func() { _ = tx.Rollback(t.Context()) }() - // Verify the database cascade independently inside an explicit transaction. - tenantID, _ := parseID(tenant) - vaultID, _ := parseID(vault.ID) - if _, err := public.queries.WithTx(tx).DeleteVault(t.Context(), sqlc.DeleteVaultParams{TenantID: tenantID, ID: vaultID}); err != nil { - t.Fatal(err) - } - var count int - if err := tx.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { - t.Fatal("cascade was not visible in the deletion transaction", err) - } - if err := tx.Rollback(t.Context()); err != nil { - t.Fatal(err) - } - if value, err := public.GetVault(t.Context(), tenant, vault.ID); err != nil || !reflect.DeepEqual(value, vault) { - t.Fatal("rollback changed the Vault", err) - } - for _, expected := range []Credential{original, extra} { - if value, err := public.GetCredential(t.Context(), tenant, vault.ID, expected.ID); err != nil || !reflect.DeepEqual(value, expected) { - t.Fatal("rollback changed a child", err) - } - } - if token, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); err != nil || token != "original-secret" { - t.Fatal("rejected deletion changed the stored token") - } - if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=decode('00','hex') WHERE vault_id=$1", vault.ID); err != nil { - t.Fatal(err) - } - for _, target := range []Vault{empty, vault} { - if id, err := public.DeleteVault(t.Context(), tenant, target.ID); err != nil || id != target.ID { - t.Fatal("keyless deletion failed", err) - } - } - if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { - t.Fatal("committed parent or encrypted children remain", err) - } - pool.Close() - public, pool = testStore(t) - cipher, _ = credentialcrypto.New(bytes.Clone(key)) - s = NewWithCredentialCipher(pool, cipher) - for _, target := range []Vault{empty, vault} { - if _, err := public.GetVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted Vault reappeared after restart") - } - if _, err := public.DeleteVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("repeated deletion did not remain absent") - } - } - for _, child := range []Credential{original, extra} { - if _, err := public.GetCredential(t.Context(), tenant, vault.ID, child.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted child reappeared") - } - if _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, child.ID, UpdateStaticCredentialInput{Token: "replacement"}); !errors.Is(err, ErrNotFound) { - t.Fatal("replacement recreated a deleted child") - } - } - if _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: "late", MCPServerURL: original.MCPServerURL, Token: "late"}); !errors.Is(err, ErrNotFound) { - t.Fatal("new child was admitted under a deleted Vault") - } - if _, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); !errors.Is(err, ErrNotFound) { - t.Fatal("frozen binding reselected a credential in another attached Vault") - } - if _, err := public.ListCredentials(t.Context(), tenant, vault.ID, "", 100, true, []string{"active", "archived"}); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted parent remained listable") - } - if value, err := public.GetVault(t.Context(), tenant, retained.ID); err != nil || !reflect.DeepEqual(value, retained) { - t.Fatal("deletion changed another Vault") - } - if value, err := public.GetCredential(t.Context(), tenant, retained.ID, sibling.ID); err != nil || !reflect.DeepEqual(value, sibling) { - t.Fatal("deletion changed another Vault's credential") - } - if retry, err := public.CreateSession(t.Context(), tenant, input); err != nil || retry.ID != session.ID || !bytes.Equal(retry.Configuration, session.Configuration) { - t.Fatal("deletion changed frozen creation identity", err) - } -} - -func TestVaultDeletionConcurrentChildMutations(t *testing.T) { - public, pool := testStore(t) - tenant := uuid.NewString() - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{44}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - for range 8 { - vault, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - input := CreateStaticCredentialInput{Name: "competing", MCPServerURL: "https://mcp.example/tools", Token: "before"} - value, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) - if err != nil { - t.Fatal(err) - } - start, created, updated, removed := make(chan struct{}), make(chan error, 1), make(chan error, 1), make(chan error, 1) - go func() { - <-start - _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) - created <- err - }() - go func() { - <-start - _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, value.ID, UpdateStaticCredentialInput{Token: "after"}) - updated <- err - }() - go func() { - <-start - _, err := public.DeleteCredential(t.Context(), tenant, vault.ID, value.ID) - removed <- err - }() - close(start) - _, deleted := public.DeleteVault(t.Context(), tenant, vault.ID) - createErr, updateErr, removeErr := <-created, <-updated, <-removed - var constraint *pgconn.PgError - if createErr != nil && !errors.Is(createErr, ErrNotFound) && !(errors.As(createErr, &constraint) && constraint.Code == "23503") { - t.Fatal("competing creation failed unexpectedly", createErr) - } - if deleted != nil || updateErr != nil && !errors.Is(updateErr, ErrNotFound) || removeErr != nil && !errors.Is(removeErr, ErrNotFound) { - t.Fatal("competing mutation failed unexpectedly", deleted, updateErr, removeErr) - } - var count int - if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { - t.Fatal("concurrent mutation resurrected deleted resources", err) - } - } -} diff --git a/services/agents-api/internal/store/vaults_list.go b/services/agents-api/internal/store/vaults_list.go deleted file mode 100644 index 22cc1a014..000000000 --- a/services/agents-api/internal/store/vaults_list.go +++ /dev/null @@ -1,59 +0,0 @@ -package store - -import ( - "context" - "fmt" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -type VaultPage struct { - Vaults []Vault - NextCursor string -} - -func (s *Store) ListVaults(ctx context.Context, tenantID, cursor string, limit int, ascending bool, statuses []string) (VaultPage, error) { - tenant, err := parseID(tenantID) - if err != nil { - return VaultPage{}, err - } - if limit < 1 || limit > 100 { - return VaultPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) - } - if len(statuses) == 0 { - statuses = []string{"active", "archived"} - } - for _, status := range statuses { - if status != "active" && status != "archived" { - return VaultPage{}, fmt.Errorf("%w: invalid Vault status", ErrInvalidInput) - } - } - params := sqlc.ListVaultsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} - if cursor != "" { - after, err := s.GetVault(ctx, tenantID, lookupCursor(cursor)) - if err != nil { - return VaultPage{}, err - } - params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} - params.AfterID, _ = parseID(after.ID) - } - rows, err := s.queries.ListVaults(ctx, params) - if err != nil { - return VaultPage{}, fmt.Errorf("list vaults: %w", err) - } - page := VaultPage{Vaults: make([]Vault, 0, min(limit, len(rows)))} - if len(rows) > limit { - page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() - rows = rows[:limit] - } - for _, row := range rows { - vault, err := vaultFromRow(row) - if err != nil { - return VaultPage{}, err - } - page.Vaults = append(page.Vaults, vault) - } - return page, nil -} diff --git a/services/agents-api/internal/store/write_audit.go b/services/agents-api/internal/store/write_audit.go deleted file mode 100644 index 120af088d..000000000 --- a/services/agents-api/internal/store/write_audit.go +++ /dev/null @@ -1,101 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "strings" - "unicode" - "unicode/utf8" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// AuditResource identifies a resource genuinely created by the current transaction. -type AuditResource struct{ Type, ID, ParentID string } - -// ValidAuditResourceType is shared by the write recorder and administrator queries. -func ValidAuditResourceType(value string) bool { - switch value { - case "agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact": - return true - } - return false -} - -func auditText(value string, max int, required bool) bool { - return (!required || value != "") && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max && !strings.ContainsFunc(value, unicode.IsControl) -} - -func validateWriteAuditSource(source writeaudit.Source, tenant string) error { - actual, err := parseID(source.TenantID) - expected, expectedErr := parseID(tenant) - valid := err == nil && expectedErr == nil && actual == expected && - auditText(source.Name, 80, false) && auditText(source.RequestID, 128, true) && auditText(source.TraceID, 128, true) - switch source.Kind { - case "static", "console": - digest := strings.TrimPrefix(source.KeyID, "static:") - valid = valid && strings.HasPrefix(source.KeyID, "static:") && validProjectKeyDigest(digest) && source.Prefix == digest[:min(len(digest), 8)] - case "issued": - _, idErr := parseID(source.KeyID) - valid = valid && idErr == nil && len(source.Prefix) == 11 && strings.HasPrefix(source.Prefix, "pc_") - for _, c := range strings.TrimPrefix(source.Prefix, "pc_") { - valid = valid && (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') - } - default: - valid = false - } - if !valid { - return fmt.Errorf("%w: invalid write audit source", ErrInvalidInput) - } - return nil -} - -// recordWriteAudit must use the caller's business transaction. Internal callers -// without a source stay unattributed; a malformed supplied source fails closed. -func recordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID, parentID string, created ...AuditResource) error { - if _, ok := adminaudit.FromContext(ctx); ok { - return recordAdminMutation(ctx, q, tenant, action, resourceType, resourceID) - } - source, ok := writeaudit.FromContext(ctx) - if !ok { - return nil - } - if err := validateWriteAuditSource(source, tenant); err != nil { - return err - } - switch action { - case "create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version": - default: - return fmt.Errorf("%w: invalid write audit action", ErrInvalidInput) - } - resources := append([]AuditResource{{Type: resourceType, ID: resourceID, ParentID: parentID}}, created...) - for _, resource := range resources { - if !ValidAuditResourceType(resource.Type) || !auditText(resource.ID, 256, true) || !auditText(resource.ParentID, 256, false) { - return fmt.Errorf("%w: invalid write audit resource", ErrInvalidInput) - } - } - tenantID, _ := parseID(tenant) - id, err := q.InsertWriteAuditOperation(ctx, sqlc.InsertWriteAuditOperationParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, - KeyID: source.KeyID, KeyName: source.Name, KeyPrefix: source.Prefix, KeyKind: source.Kind, - Action: action, ResourceType: resourceType, ResourceID: resourceID, ParentID: parentID, RequestID: source.RequestID, TraceID: source.TraceID, - }) - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - if err != nil { - return err - } - for _, resource := range created { - if err := q.InsertWriteAuditOwner(ctx, sqlc.InsertWriteAuditOwnerParams{TenantID: tenantID, ResourceType: resource.Type, ResourceID: resource.ID, ParentID: resource.ParentID, OperationID: id}); err != nil { - return err - } - } - return nil -} diff --git a/services/agents-api/internal/store/write_audit_test.go b/services/agents-api/internal/store/write_audit_test.go deleted file mode 100644 index 90a3412fe..000000000 --- a/services/agents-api/internal/store/write_audit_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package store - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func auditTestSource(tenant string) writeaudit.Source { - digest := projectKeyDigest(uuid.NewString()) - return writeaudit.Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "test key", Kind: "static", TenantID: tenant, RequestID: uuid.NewString(), TraceID: uuid.NewString()} -} - -func auditTestRecord(t *testing.T, s *Store, source writeaudit.Source, action, kind, id string, created ...AuditResource) { - t.Helper() - ctx := writeaudit.WithSource(t.Context(), source) - if err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { - return recordWriteAudit(ctx, s.queries.WithTx(tx), source.TenantID, action, kind, id, "", created...) - }); err != nil { - t.Fatal(err) - } -} - -func TestWriteAuditAtomicCommitAndRollback(t *testing.T) { - s, pool := testStore(t) - tenant := uuid.NewString() - source := auditTestSource(tenant) - for _, failure := range []string{"", "after_audit", "invalid_source", "database_audit_failure"} { - t.Run(failure, func(t *testing.T) { - id := uuid.NewString() - source.RequestID = uuid.NewString() - if failure == "invalid_source" { - source.TraceID = "" - } else { - source.TraceID = uuid.NewString() - } - ctx := writeaudit.WithSource(t.Context(), source) - err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - tenantUUID, _ := parseID(tenant) - _, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ID: pgtype.UUID{Bytes: uuid.MustParse(id), Valid: true}, TenantID: tenantUUID, Metadata: []byte("{}"), Configuration: []byte("{}")}) - if err != nil { - return err - } - if failure == "database_audit_failure" { - // This transaction-local constraint deliberately rejects the audit insert. - if _, err := tx.Exec(ctx, "ALTER TABLE write_audit_operations ADD CONSTRAINT audit_test_failure CHECK (false) NOT VALID"); err != nil { - return err - } - } - if err := recordWriteAudit(ctx, q, tenant, "create", "agent", id, "", AuditResource{Type: "agent", ID: id}); err != nil { - return err - } - if failure == "after_audit" { - return errors.New("business failure after audit") - } - return nil - }) - if (err != nil) != (failure != "") { - t.Fatalf("commit result: %v", err) - } - _, agentErr := s.GetAgent(t.Context(), tenant, id) - if failure == "" && agentErr != nil || failure != "" && !errors.Is(agentErr, ErrNotFound) { - t.Fatalf("business rollback: %v", agentErr) - } - page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) - if err != nil { - t.Fatal(err) - } - want := 0 - if failure == "" { - want = 1 - } - if len(page.Data) != want { - t.Fatalf("audit count %d want %d", len(page.Data), want) - } - owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) - if err != nil || (owners[0].APIKey != nil) != (failure == "") { - t.Fatalf("ownership rollback: %+v %v", owners, err) - } - }) - } - // A context without authenticated provenance is an intentional internal write. - if err := recordWriteAudit(context.Background(), nil, tenant, "create", "agent", uuid.NewString(), ""); err != nil { - t.Fatal(err) - } -} - -func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { - s, _ := testStore(t) - principal := projectKeyPrincipal() - tenant := principal.TenantID - a := auditTestSource(tenant) - id, implicit := uuid.NewString(), uuid.NewString() - auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: id}, AuditResource{Type: "environment", ID: implicit, ParentID: id}) - // Same request may reach a commit receipt twice but cannot create another owner. - replayID := uuid.NewString() - auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: replayID}) - b := auditTestSource(tenant) - b.Kind = "console" - auditTestRecord(t, s, b, "update", "session", id) - owners, err := s.GetResourceOwners(t.Context(), tenant, "session", []string{replayID, id, id, "historical"}) - if err != nil || len(owners) != 4 || owners[0].APIKey != nil || owners[1].APIKey.ID != a.KeyID || owners[2].APIKey.ID != a.KeyID || owners[3].APIKey != nil { - t.Fatalf("owners %+v: %v", owners, err) - } - implicitOwners, err := s.GetResourceOwners(t.Context(), tenant, "environment", []string{implicit}) - if err != nil || implicitOwners[0].APIKey.ID != a.KeyID { - t.Fatalf("implicit owner: %+v %v", implicitOwners, err) - } - foreign, err := s.GetResourceOwners(t.Context(), uuid.NewString(), "session", []string{id}) - if err != nil || foreign[0].APIKey != nil { - t.Fatalf("foreign owner: %+v %v", foreign, err) - } - page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) - if err != nil || len(page.Data) != 2 || page.Data[0].APIKey.Kind != "console" || page.Data[1].APIKey.ID != a.KeyID { - t.Fatalf("request dedup or key identity: %+v %v", page, err) - } - project := createTestProject(t, s) - issued, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), project.ID), project.ID, uuid.NewString(), "issued key") - if err != nil { - t.Fatal(err) - } - tenant = project.TenantID - c := auditTestSource(tenant) - c.KeyID, c.Name, c.Prefix, c.Kind = issued.ID, issued.Name, issued.Prefix, "issued" - fileID := "file_" + uuid.NewString() - auditTestRecord(t, s, c, "create", "file", fileID, AuditResource{Type: "file", ID: fileID}) - if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), project.ID), project.ID, issued.ID); err != nil { - t.Fatal(err) - } - revoked, err := s.GetResourceOwners(t.Context(), tenant, "file", []string{fileID}) - if err != nil || revoked[0].APIKey.RevokedAt == nil || revoked[0].APIKey.Name != issued.Name { - t.Fatalf("revocation metadata: %+v %v", revoked, err) - } - page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{KeyID: c.KeyID}) - if err != nil || len(page.Data) != 1 || page.Data[0].APIKey.RevokedAt == nil { - t.Fatalf("history revocation: %+v %v", page, err) - } -} - -// The copy operation was removed; its committed provenance must stay readable. -func TestHistoricalAdminCopyProvenance(t *testing.T) { - s, pool := testStore(t) - project := createTestProject(t, s) - auditID, agentID := uuid.NewString(), uuid.NewString() - if _, err := pool.Exec(t.Context(), `INSERT INTO admin_audit_log(id,tenant_id,project_id,admin_credential_id,actor_label,action,resource_type,resource_id,result_ids,request_id,trace_id) - VALUES($1,$2,$3,'digest','admin','copy','agent','source-agent',$4::jsonb,'request','trace')`, auditID, project.TenantID, project.ID, `[{"type":"agent","source_id":"source-agent","target_id":"`+agentID+`"}]`); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,audit_id) VALUES($1,'agent',$2,$3)", project.TenantID, agentID, auditID); err != nil { - t.Fatal(err) - } - owners, err := s.GetResourceOwners(t.Context(), project.TenantID, "agent", []string{agentID}) - if err != nil || len(owners) != 1 || owners[0].APIKey != nil || owners[0].Source == nil || *owners[0].Source != "admin_copy" || owners[0].AdminAuditID == nil || *owners[0].AdminAuditID != auditID { - t.Fatalf("historical copy owner: %+v %v", owners, err) - } - page, err := s.ListAdminAudit(t.Context(), AdminAuditFilter{ProjectID: project.ID, Action: "copy"}) - if err != nil || len(page.Data) != 1 || page.Data[0].ID != auditID || !strings.Contains(string(page.Data[0].ResultIDs), agentID) { - t.Fatalf("historical copy audit: %+v %v", page, err) - } -} - -func TestWriteAuditCursorFiltersAndRetention(t *testing.T) { - s, pool := testStore(t) - tenant := uuid.NewString() - source := auditTestSource(tenant) - agent, err := s.CreateAgent(t.Context(), tenant, CreateAgentInput{Configuration: []byte("{}")}) - if err != nil { - t.Fatal(err) - } - id := agent.ID - auditTestRecord(t, s, source, "create", "agent", id, AuditResource{Type: "agent", ID: id}) - for i := 0; i < 4; i++ { - source.RequestID = uuid.NewString() - auditTestRecord(t, s, source, "update", "agent", id) - } - // Equal timestamps exercise the ID tie breaker, independent of insertion order. - stamp := time.Date(1800, 1, 1, 0, 0, 0, 0, time.UTC) - if _, err := pool.Exec(t.Context(), "UPDATE write_audit_operations SET created_at=$1 WHERE tenant_id=$2", stamp, tenant); err != nil { - t.Fatal(err) - } - first, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2}) - if err != nil || len(first.Data) != 2 || !first.HasMore || first.NextCursor == "" { - t.Fatalf("first %+v %v", first, err) - } - seen := map[string]bool{} - page := first - for { - for _, row := range page.Data { - if seen[row.ID] { - t.Fatal("duplicate cursor item") - } - seen[row.ID] = true - } - if !page.HasMore { - break - } - page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2, After: page.NextCursor}) - if err != nil { - t.Fatal(err) - } - } - if len(seen) != 5 { - t.Fatalf("lost rows %d", len(seen)) - } - for _, filter := range []WriteOperationFilter{{Limit: 2, After: first.NextCursor, KeyID: "different"}, {After: "malformed"}} { - if _, err := s.ListWriteOperations(t.Context(), tenant, filter); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("cursor filter: %v", err) - } - } - if _, err := s.ListWriteOperations(t.Context(), uuid.NewString(), WriteOperationFilter{After: first.NextCursor}); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("cross tenant cursor: %v", err) - } - for _, filter := range []WriteOperationFilter{{CreatedBefore: &stamp}, {KeyID: "missing"}, {ResourceType: "file"}, {ResourceID: "missing"}} { - page, err := s.ListWriteOperations(t.Context(), tenant, filter) - if err != nil || len(page.Data) != 0 { - t.Fatalf("filter %+v: %+v %v", filter, page, err) - } - } - inclusive, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{CreatedAfter: &stamp}) - if err != nil || len(inclusive.Data) != 5 { - t.Fatalf("inclusive lower bound: %+v %v", inclusive, err) - } - cutoff := stamp.Add(time.Hour) - n, err := s.DeleteExpiredWriteOperations(t.Context(), cutoff, 2) - if err != nil || n != 2 { - t.Fatalf("bounded retention %d %v", n, err) - } - n, err = s.DeleteExpiredWriteOperations(t.Context(), cutoff, 1000) - if err != nil || n != 2 { - t.Fatalf("remaining retention %d %v", n, err) - } - page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{}) - if err != nil || len(page.Data) != 1 || page.Data[0].Action != "create" { - t.Fatalf("creator retention %+v %v", page, err) - } - // Deleting the business resource cannot cascade through provenance. - if _, err := pool.Exec(t.Context(), "DELETE FROM agents WHERE tenant_id=$1 AND id=$2", tenant, id); err != nil { - t.Fatal(err) - } - owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) - if err != nil || owners[0].APIKey == nil { - t.Fatalf("deleted creator %+v %v", owners, err) - } -} - -func TestWriteAuditSourceValidation(t *testing.T) { - valid := auditTestSource(uuid.NewString()) - for _, field := range []string{"tenant", "key", "prefix", "kind", "request", "trace", "name"} { - source := valid - switch field { - case "tenant": - source.TenantID = uuid.NewString() - case "key": - source.KeyID = "static:abcd" - case "prefix": - source.Prefix = "bad" - case "kind": - source.Kind = "unknown" - case "request": - source.RequestID = "" - case "trace": - source.TraceID = "" - case "name": - source.Name = strings.Repeat("x", 81) - } - ctx := writeaudit.WithSource(t.Context(), source) - if err := recordWriteAudit(ctx, nil, valid.TenantID, "create", "agent", "resource", ""); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("%s accepted: %v", field, err) - } - } -} diff --git a/services/agents-api/tests/fixtures/main.go b/services/agents-api/tests/fixtures/main.go deleted file mode 100644 index 0db13ebe0..000000000 --- a/services/agents-api/tests/fixtures/main.go +++ /dev/null @@ -1,134 +0,0 @@ -// Command fixtures prepares internal records for official-client recovery tests. -package main - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" -) - -type fixture struct { - Tenant string `json:"tenant"` - Session string `json:"session"` - Turns []string `json:"turns"` -} - -func main() { - if err := seed(); err != nil { - os.Stderr.WriteString("Internal fixture setup failed.\n") - os.Exit(1) - } -} - -func seed() error { - if path := os.Getenv("OAC_TEST_PROJECT_IDENTITIES_FIXTURE"); path != "" { - return readProjectIdentities(path) - } - if path := os.Getenv("OAC_TEST_CREDENTIAL_LIST_FIXTURE"); path != "" { - return seedCredentialList(path) - } - if path := os.Getenv("OAC_TEST_VAULT_LIST_FIXTURE"); path != "" { - return seedVaultList(path) - } - path := os.Getenv("OAC_TEST_TURN_FIXTURE") - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var f fixture - if err = json.Unmarshal(raw, &f); err != nil { - return err - } - ctx := context.Background() - pool, err := fixturePool(ctx) - if err != nil { - return err - } - defer pool.Close() - s := store.New(pool) - for _, status := range []string{store.TurnCompleted, store.TurnFailed, store.TurnCancelled, store.TurnInProgress} { - receipt, err := s.SubmitMessage(ctx, f.Tenant, f.Session, uuid.NewString(), json.RawMessage(`{"text":"recovery fixture"}`)) - if err != nil { - return err - } - if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { - return err - } - if err = observeItems(ctx, s, f.Tenant, f.Session, receipt.TurnID, status); err != nil { - return err - } - if status != store.TurnInProgress { - outcome := json.RawMessage(`{"error":"SECRET engine log","done":{"metadata":{"agent_session_id":"PRIVATE"}}}`) - if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: status, Outcome: outcome}); err != nil { - return err - } - } - f.Turns = append(f.Turns, receipt.TurnID) - } - raw, err = json.Marshal(f) - if err != nil { - return err - } - return os.WriteFile(path, raw, 0600) -} - -func fixturePool(ctx context.Context) (*pgxpool.Pool, error) { - cfg, err := pgxpool.ParseConfig(os.Getenv("OAC_TEST_DATABASE_URL")) - if err != nil { - return nil, err - } - if !strings.HasPrefix(cfg.ConnConfig.Database, "oac_") || !strings.HasSuffix(cfg.ConnConfig.Database, "_tests") { - return nil, errors.New("dedicated test database required") - } - return pgxpool.NewWithConfig(ctx, cfg) -} - -// readProjectIdentities exports only the scope required by internal test fixtures. -// Projects and credentials are created through the administrator HTTP API. -func readProjectIdentities(path string) error { - raw, err := os.ReadFile(path) - if err != nil { - return err - } - var f struct { - ProjectIDs []string `json:"project_ids"` - Bindings []json.RawMessage `json:"bindings"` - } - if err = json.Unmarshal(raw, &f); err != nil { - return err - } - ctx := context.Background() - pool, err := fixturePool(ctx) - if err != nil { - return err - } - defer pool.Close() - for _, id := range f.ProjectIDs { - projectID, err := uuid.Parse(id) - if err != nil { - return err - } - var binding json.RawMessage - err = pool.QueryRow(ctx, `SELECT json_build_object( - 'tenant_id', p.tenant_id, 'organization_id', s.organization_id, - 'project_id', s.project_id, 'subject_kind', p.subject_kind, - 'subject_id', p.subject_id) - FROM projects p JOIN execution_project_scopes s ON s.tenant_id=p.tenant_id - WHERE p.id=$1`, projectID).Scan(&binding) - if err != nil { - return err - } - f.Bindings = append(f.Bindings, binding) - } - raw, err = json.Marshal(f) - if err != nil { - return err - } - return os.WriteFile(path, raw, 0600) -} diff --git a/services/agents-api/tools/e2b-provider/README.md b/services/agents-api/tools/e2b-provider/README.md deleted file mode 100644 index 5411c15f2..000000000 --- a/services/agents-api/tools/e2b-provider/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# Managed E2B SDK helper - -Core's Go adapter invokes this one-shot helper for Create, GetInfo, Renew, Kill -and initialization RunCommand. Daily execution and Files remain on the existing -Runtime connection. The helper uses the official E2B Python SDK 2.51.0; it does -not implement provider HTTP, envd RPC, a scheduler or a network service. - -Managed deployment validation uses a separate read-only helper request, bounded -to 30 seconds. The pinned SDK reads the selected template's build inventory and -requires the exact build UUID to be ready with the configured CPU and memory; a -selection without resources adopts the ready build's CPU and memory. It returns -the build's status, CPU, memory and reported disk size for Core to record with -the selection, and creates neither compute nor allocation receipts. - -The console's Core-key-only setup flow uses two more read-only helper requests. -`list_templates` pages the credential's visible templates through the official -`GET /v2/templates` SDK operation; `list_builds` -pages one selected template and returns ready exact build IDs and resources. -Both operations use the same explicit endpoint selectors and a transient API -key. They cap results at 200, never write a receipt, and cannot replace the -deployment write's exact-build validation. -Compatible endpoints must return the E2B SDK 2.51.0 template-list and -template-build response models. The helper does not adapt provider-specific -catalog shapes. - -Runtime observation uses a third read-only request, `observe`, for at most 100 -Runtime observation uses a separate read-only request, `observe`, for at most 100 -allocations. It reads each allocation's sandbox ID from its receipt without the -allocation lock, then runs one `GET /sandboxes/metrics` request and one labelled -listing of this installation's running sandboxes concurrently, within the -caller's deadline; the listing stops once every requested sandbox has appeared. -Only a sandbox that the listing confirms for exactly that allocation is -reported, with the listing's start time. A malformed metrics point makes only -its row unavailable. It never connects to, -renews or changes a sandbox and never writes receipts. See -[Runtime observability](../../../../contracts/agents-api/runtime-observability.md). Actual sandbox information -is checked before writing bootstrap credentials and on subsequent inspection; -resource drift still permits ownership-based cleanup. E2B disk capacity is not -an independently configurable limit. Sandbox inspection does not expose a build -UUID: build provenance comes from the validated immutable create selector. - -Credential replacement uses `verify_credential`, a read-only request with at most -32 Core allocation references. It verifies the fixed build, the SDK's paginated -team-owned template listing, and each settled live receipt against the labelled -sandbox listing. Template and sandbox scans each stop at 100 pages or the caller's -30-second deadline. Missing/unsettled receipts, repeated template cursors and -unconfirmed reads never authorize replacement. No receipt is changed. Core scans -retained generations and allocation pages under one bounded verification context, -then repeats verification while provider calls are fenced before credential commit. -Authentication rejection, ownership mismatch and uncertainty remain distinct fixed -codes. A readable public template is not proof that the key owns it. - -The private JSON boundary has version 1. Requests and credentials enter stdin; -stdout contains one bounded response with sanitized error codes. API keys never -enter arguments, inherited environment or receipts. The process retains its -allocation lock when the Core caller times out, until the bounded SDK operation -returns. Core must serialize lifecycle requests and never replay Create. -The request carries the deployment's explicit API origin and sandbox domain. -Every SDK call uses these selectors after ambient `E2B_*` variables are removed. -Receipts bind an allocation to those selectors; receipts written before this -feature belong to official E2B. Endpoint changes retain earlier generations on their original API and data-plane domain. The candidate credential must verify all retained ownership before an online switch. -Core tracks actual child exit even after caller timeout. Credential fencing -waits for those children without killing them; child exit itself never proves remote -Create settled. Core must serialize lifecycle requests and never replay Create. - -`StateDir` must already exist, be owned by the service user and have mode 0700. -Keep it on durable private storage through Core upgrades/restarts. Its receipts -contain provider connection credentials, ownership identities and one-shot -creation claims, not Core execution state. Losing this directory cannot authorize -recreation or successful cleanup. Do not delete receipts after an uncertain call. - -GetInfo uses SDK metadata/ID reads. SDK `connect` is never used because it can -resume paused compute. The SDK's version-pinned constructor restores clients -from private connection material; this deprecated constructor is intentionally -contained in `sdk.py` and covered by a no-connect/no-create test. An unknown -Create without connection material can be discovered and reclaimed, but cannot -resume bootstrap. Empty lookup cannot settle an unknown Create. Cleanup retains -every matching candidate and confirms exact-ID absence before writing a tombstone. - -`CreateSettled` proves the original Create/bootstrap can no longer mutate. It is -independent of `BootstrapComplete`, which acknowledges the protected initializer's -last step, not enrollment or native readiness. Explicitly settled absence returns -successful Info with `State=absent`; ordinary missing compute has no such proof. -An explicitly rejected Create with a settled receipt and no provider IDs proves -absence without another cloud request. GetInfo and Kill retain that rejection -receipt, so repeated recovery remains possible even when the API key is invalid. -Other receipts still require cloud discovery and ownership checks. -Unconfirmed initialization commands require reclaiming the whole allocation. - -## Build - -The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) -builds the helper. The artifact contains only regular files and directories, with -executable permissions preserved, including the native `pyqwest` and -`protobuf-py-ext` wheels. `--check` needs no account credential. The installation -owns the durable receipt path independently of this immutable helper payload. - -`deploy/e2b/build-template.py` packages `init.py` and `managed_init.py` with the -qualified Runtime image. Existing templates without these files must be rebuilt. -The shared protected image preparation is used by both managed and self-hosted -startup; their credential formats and one-shot receipts remain separate. - -## Verification - -```sh -python -m unittest discover -s services/agents-api/tools/e2b-provider -p '*_test.py' -v -python -m unittest discover -s services/agents-api/deploy/e2b -p '*_test.py' -v -``` - -The build runs the first suite and validates the relocated helper's `--check` -report. These checks do not establish cloud authentication, native isolation or -real execution. Live acceptance must use owned E2B compute and the same Runtime, -with actual lease renewal, restart/unknown outcome reconciliation and confirmed -cleanup. Initializer and three-harness qualification remain deployment checks. - -## Adapter rules - -Core-managed E2B is a separate hosted deployment choice, using the official pinned -Python SDK through a packaged private helper. Do not restore the retired custom -HTTP/Connect or envd implementation. The adapter implements the same five operations; -cloud allocations use direct placement with no synthetic node, while Runtime execution -and file access keep the shared daemon contract. Its immutable Runtime template build -is deployment configuration, not a public Environment Template. Keep the account API -key encrypted in the database, write-only through admin input and absent from helper -arguments, logs, metadata and receipts. SDK connection materials and attempted-create -receipts belong in the private durable provider state directory; never replace missing -state to make cleanup appear successful. Create runs once. Unknown control-plane -outcomes remain blockers even if a listing is empty. Explicit matching-reference -CreateSettled evidence proves that the original initialization cannot mutate further; -confirmed absent compute may then be released. Ordinary 404 responses do not prove it. -The helper's pinned SDK, dependencies and licenses ship with Core; users do not install -Python packages after selecting E2B in Web. Application-managed self_hosted tooling -remains independent and uses the same Runtime. Qualify each changed path using actual -provider and model execution before claiming acceptance. Run `make check-e2b-provider` -with `OAC_TEST_E2B_SDK_PYTHON` pointing to the pinned SDK environment; the packaged -helper build runs the provider tests as well. The SDK gate also covers the -application-managed launch tests. `make check` covers shared initialization and -managed initialization using only the Python standard library. diff --git a/services/agents-api/tools/microsandbox-provider/bootstrap.go b/services/agents-api/tools/microsandbox-provider/bootstrap.go deleted file mode 100644 index 7eef9c4c3..000000000 --- a/services/agents-api/tools/microsandbox-provider/bootstrap.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build linux - -package main - -import ( - "context" - "encoding/json" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - sdk "github.com/superradcompany/microsandbox/sdk/go" -) - -// Runtime reads the shared launch input; its private auth storage stays opaque. -// All credential bytes enter the guest on stdin before any native work is admitted. -const bootstrapScript = ` -import ctypes,json,os,stat,subprocess,sys -b=json.load(sys.stdin) -for p in ['/home/runtime','/home/runtime/.oac','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages','/run/oac']: - os.makedirs(p,mode=0o700,exist_ok=True) - if not stat.S_ISDIR(os.lstat(p).st_mode): raise RuntimeError('invalid bootstrap directory') - os.chmod(p,0o700);os.chown(p,1000,1000) -p='/home/runtime/runtime-bootstrap.json' -fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) -with os.fdopen(fd,'w') as f: - json.dump(b,f) - f.flush();os.fsync(f.fileno());os.fchown(f.fileno(),1000,1000) -if not stat.S_ISDIR(os.lstat('/workspace').st_mode): raise RuntimeError('invalid workspace alias') -libc=ctypes.CDLL(None,use_errno=True) -if libc.mount(b'/environment/workspace',b'/workspace',None,4096,None)!=0: - raise OSError(ctypes.get_errno(),'workspace bind mount failed') -def runtime_user(): - os.setgroups([]);os.setgid(1000);os.setuid(1000) -subprocess.run(['/usr/local/bin/oac-daemon','connect','--profile','default','--bootstrap-file',p,'-b'], - stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, - cwd='/environment/workspace',preexec_fn=runtime_user,check=True) -` - -func (b backend) create(ctx context.Context) (wire.Response, error) { - c := wire.Compute{Name: wire.Name(b.q.Config, b.q.Reference, 0)} - if _, _, e := b.inspect(ctx, c); e == nil { - return wire.Response{}, sandbox.ErrExists - } else if !sdk.IsKind(e, sdk.ErrSandboxNotFound) { - return wire.Response{}, e - } - bootstrap := *b.q.Bootstrap - policy := agentnetwork.Policy{Access: bootstrap.NetworkAccess, AllowedDomains: bootstrap.AllowedDomains} - domains, _ := json.Marshal(policy.Hosts()) - labels := wire.Labels(b.q.Config, b.q.Reference) - labels[bootstrapLabel] = "pending" - live, e := sdk.CreateSandbox(ctx, c.Name, - sdk.WithImage(b.q.Config.Image), sdk.WithMemory(b.q.Config.MemoryMiB), sdk.WithCPUs(b.q.Config.CPUs), - sdk.WithMaxMemory(b.q.Config.MemoryMiB), sdk.WithMaxCPUs(b.q.Config.CPUs), - sdk.WithRootDisk(sdk.RootDisk.Managed(b.q.Config.RootDiskMiB)), sdk.WithUser("1000:1000"), - // A native owned disk keeps workspace and staging on one filesystem. - // Bootstrap creates their directories before starting the daemon. - sdk.WithWorkdir("/"), sdk.WithMounts(map[string]sdk.MountConfig{ - "/environment": sdk.Mount.Owned(sdk.OwnedVolumeOptions{Kind: sdk.VolumeKindDisk, SizeMiB: b.q.Config.EnvironmentDiskMiB}), - }), - sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(b.network()), - sdk.WithEnv(map[string]string{ - "HOME": "/home/runtime", "OAC_RUNTIME_HOME": "/home/runtime/.oac", - "OAC_RUNTIME_ENVIRONMENT_ID": bootstrap.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bootstrap.SessionID, - "OAC_RUNTIME_NETWORK_ACCESS": policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS": string(domains), - "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE": "/run/oac/daemon-suspend.json", - })) - if e != nil { - return wire.Response{}, e - } - defer live.Detach(context.Background()) - c.ID = live.ID() - h, e := sdk.GetSandbox(ctx, c.Name) - if e != nil { - return wire.Response{}, e - } - qualified, e := qualifyCreatedConfiguration(b.q.Config, b.q.Reference, c, h.ID(), string(h.Status()), h.ConfigJSON()) - if e != nil { - return qualified, e - } - data, e := bootstrap.RuntimeConnection().Marshal() - if e != nil { - return wire.Response{}, e - } - initialization, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - result, e := runCommand(initialization, live, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", bootstrapScript}, Stdin: data}, "0:0") - if e != nil { - return wire.Response{}, e - } - if result.ExitCode != 0 { - return wire.Response{}, sandbox.ErrCommandUnconfirmed - } - // Persist the final bootstrap receipt without restarting the live guest. - // v0.7.2 cannot update active labels; ownership reads persisted config. - _, e = live.Modify(ctx, sdk.ModifyOptions{Labels: map[string]string{bootstrapLabel: "complete"}, Policy: sdk.ModificationPolicyNextStart}) - if e != nil { - return wire.Response{}, e - } - _, state, e := b.inspect(ctx, c) - if e == nil && !state.BootstrapComplete { - return wire.Response{}, sandbox.ErrCommandUnconfirmed - } - return wire.Response{State: &state}, e -} - -// Only the initial post-Create inspection uses this proof. Native creation has -// returned successfully, and no bootstrap command has started. Ordinary inspect -// and unknown Create outcomes cannot acquire settlement through this path. -func qualifyCreatedConfiguration(config wire.Config, ref sandbox.Reference, created wire.Compute, actualID, status, raw string) (wire.Response, error) { - if created.ID == "" { - return wire.Response{}, sandbox.ErrOwnership - } - state, err := qualifyCompute(config, ref, created, actualID, status, raw) - if err != nil { - return wire.Response{}, err - } - if state.Status == "" || state.Status == "absent" || state.BootstrapComplete { - return wire.Response{}, sandbox.ErrOwnership - } - if err := qualifyConfiguration(config, created, raw, false); err != nil { - return wire.Response{State: &state, CreateSettled: true}, err - } - return wire.Response{State: &state}, nil -} diff --git a/services/agents-api/tools/microsandbox-provider/command.go b/services/agents-api/tools/microsandbox-provider/command.go deleted file mode 100644 index 551681511..000000000 --- a/services/agents-api/tools/microsandbox-provider/command.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build linux - -package main - -import ( - "bytes" - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - sdk "github.com/superradcompany/microsandbox/sdk/go" -) - -func runCommand(ctx context.Context, live *sdk.Sandbox, c sandbox.Command, user string) (sandbox.CommandResult, error) { - var result sandbox.CommandResult - deadline, ok := ctx.Deadline() - if !ok || wire.ValidateCommand(c) != nil { - return result, sandbox.ErrInvalid - } - timeout := time.Until(deadline) - if timeout <= 0 { - return result, sandbox.ErrCommandUnconfirmed - } - options := []sdk.ExecOption{sdk.WithExecUser(user), sdk.WithExecCwd(c.Directory), sdk.WithExecTimeout(timeout), sdk.WithExecStdinPipe()} - handle, e := live.ExecStream(ctx, c.Args[0], c.Args[1:], options...) - if e != nil { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) - } - defer handle.Close() - sink := handle.TakeStdin() - if sink == nil { - return result, sandbox.ErrCommandUnconfirmed - } - // Write and receive concurrently to avoid full-pipe deadlocks. - written := make(chan error, 1) - go func() { - data := c.Stdin - for len(data) > 0 { - n := len(data) - if n > 65536 { - n = 65536 - } - count, err := sink.WriteCtx(ctx, data[:n]) - if err != nil { - written <- err - return - } - if count != n { - written <- errors.New("short command input") - return - } - data = data[n:] - } - written <- sink.Close() - }() - return collectCommand(ctx, handle.Recv, written) -} - -func collectCommand(ctx context.Context, receive func(context.Context) (*sdk.ExecEvent, error), written <-chan error) (sandbox.CommandResult, error) { - var result sandbox.CommandResult - var stdout, stderr bytes.Buffer - exited := false - for { - event, err := receive(ctx) - if err != nil { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, err) - } - switch event.Kind { - case sdk.ExecEventStdout: - if stdout.Len()+len(event.Data) > wire.MaxOutputBytes { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - _, _ = stdout.Write(event.Data) - case sdk.ExecEventStderr: - if stderr.Len()+len(event.Data) > wire.MaxOutputBytes { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - _, _ = stderr.Write(event.Data) - case sdk.ExecEventExited: - if exited { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - exited = true - result.ExitCode = event.ExitCode - case sdk.ExecEventStdinError, sdk.ExecEventFailed: - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - case sdk.ExecEventDone: - if !exited { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - select { - case err := <-written: - if err != nil { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - result.Stdout, result.Stderr = stdout.String(), stderr.String() - return result, nil - case <-ctx.Done(): - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - } - } -} diff --git a/services/agents-api/tools/microsandbox-provider/creation_settlement_test.go b/services/agents-api/tools/microsandbox-provider/creation_settlement_test.go deleted file mode 100644 index b495008ed..000000000 --- a/services/agents-api/tools/microsandbox-provider/creation_settlement_test.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" -) - -func TestCreatedConfigurationRejectionRequiresExactOwnedCompute(t *testing.T) { - for _, name := range []string{"matching", "resource drift", "image drift", "foreign ID", "foreign labels", "missing created ID", "bootstrap complete", "absent", "malformed"} { - t.Run(name, func(t *testing.T) { - config, actual := deploymentFixture() - ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"} - created := wire.Compute{Name: wire.Name(config, ref, 0), ID: "local:created"} - labels := wire.Labels(config, ref) - labels[bootstrapLabel] = "pending" - actual["labels"] = labels - actualID, status := created.ID, "running" - switch name { - case "resource drift": - actual["resources"].(map[string]any)["cpus"] = 1 - case "image drift": - actual["image"] = "runtime:latest" - case "foreign ID": - actualID = "local:foreign" - case "foreign labels": - actual["labels"] = map[string]string{} - case "missing created ID": - created.ID = "" - case "bootstrap complete": - labels[bootstrapLabel] = "complete" - case "absent": - status = "absent" - } - raw, _ := json.Marshal(actual) - if name == "malformed" { - raw = []byte(`{"labels":`) - } - result, err := qualifyCreatedConfiguration(config, ref, created, actualID, status, string(raw)) - settled := name == "resource drift" || name == "image drift" - if result.CreateSettled != settled || (err == nil) != (name == "matching") { - t.Fatal("configuration result changed creation proof", result, err) - } - if settled { - if !errors.Is(err, sandbox.ErrInvalid) || result.State == nil || result.State.Compute != created || result.State.BootstrapComplete { - t.Fatal("rejection did not preserve exact unbootstrapped compute", result, err) - } - } else if name != "matching" && result.State != nil { - t.Fatal("unverified compute returned a receipt", result) - } - }) - } -} diff --git a/services/agents-api/tools/microsandbox-provider/deployment.go b/services/agents-api/tools/microsandbox-provider/deployment.go deleted file mode 100644 index 11cc7c363..000000000 --- a/services/agents-api/tools/microsandbox-provider/deployment.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build linux - -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - sdk "github.com/superradcompany/microsandbox/sdk/go" -) - -const resourceProofLabel = "io.oac.resource-proof" - -func resourceProof(config wire.Config) string { - raw, _ := json.Marshal(struct { - Image string - Resources sandbox.Resources - }{config.Image, sandbox.Resources{CPUs: uint32(config.CPUs), MemoryMiB: config.MemoryMiB, - RootDiskMiB: config.RootDiskMiB, EnvironmentDiskMiB: config.EnvironmentDiskMiB}}) - digest := sha256.Sum256(raw) - return hex.EncodeToString(digest[:]) -} - -// The SDK decodes native CPU/memory/rootfs configuration. Its v0.7.2 projection -// omits mounts, so only the owned disk inventory is projected separately here. -// Restored roots have no configured size: a verified full snapshot supplies that -// proof, retained as a label only after inspecting the restored target. -func qualifyConfiguration(config wire.Config, compute wire.Compute, raw string, inheritedRoot bool) error { - var actual sdk.SandboxConfig - if json.Unmarshal([]byte(raw), &actual) != nil || actual.Image != config.Image || - actual.CPUs != config.CPUs || actual.MaxCPUs != config.CPUs || - actual.MemoryMiB != config.MemoryMiB || actual.MaxMemoryMiB != config.MemoryMiB || - actual.RootDisk == nil || actual.RootDisk.Kind() != sdk.RootDiskKindManaged { - return sandbox.ErrInvalid - } - if actual.RootDisk.SizeMiB != config.RootDiskMiB { - if actual.RootDisk.SizeMiB != 0 || compute.RestoredFrom == nil || - (!inheritedRoot && actual.Labels[resourceProofLabel] != resourceProof(config)) { - return sandbox.ErrInvalid - } - } - var inventory struct { - Mounts []struct { - Type string `json:"type"` - Guest string `json:"guest"` - Storage struct { - Kind string `json:"kind"` - CapacityMiB uint32 `json:"capacity_mib"` - } `json:"storage"` - } `json:"mounts"` - } - if json.Unmarshal([]byte(raw), &inventory) != nil || len(inventory.Mounts) != 1 { - return sandbox.ErrInvalid - } - mount := inventory.Mounts[0] - if mount.Type != "Owned" || mount.Guest != "/environment" || mount.Storage.Kind != "disk" || mount.Storage.CapacityMiB != config.EnvironmentDiskMiB { - return sandbox.ErrInvalid - } - return nil -} - -func qualifySnapshotResources(config wire.Config, labels map[string]string) error { - if labels[resourceProofLabel] != resourceProof(config) { - return sandbox.ErrInvalid - } - return nil -} diff --git a/services/agents-api/tools/microsandbox-provider/deployment_test.go b/services/agents-api/tools/microsandbox-provider/deployment_test.go deleted file mode 100644 index b1fb9b70a..000000000 --- a/services/agents-api/tools/microsandbox-provider/deployment_test.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" -) - -func deploymentFixture() (wire.Config, map[string]any) { - config := wire.Config{Image: "runtime@sha256:" + strings.Repeat("a", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 4096} - actual := map[string]any{ - "image": map[string]any{"Oci": map[string]any{"reference": config.Image, "root_disk": map[string]any{"kind": "managed", "size_mib": 8192}}}, - "resources": map[string]any{"cpus": 2, "max_cpus": 2, "memory_mib": 2048, "max_memory_mib": 2048}, - "mounts": []any{map[string]any{"type": "Owned", "guest": "/environment", "storage": map[string]any{"kind": "disk", "capacity_mib": 4096}}}, - } - return config, actual -} - -func TestNativeDeploymentConfigurationRejectsDrift(t *testing.T) { - for _, field := range []string{"match", "cpus", "max_cpus", "memory_mib", "max_memory_mib", "image", "root", "environment", "extra_mount", "missing_mount"} { - t.Run(field, func(t *testing.T) { - config, actual := deploymentFixture() - switch field { - case "cpus", "max_cpus", "memory_mib", "max_memory_mib": - actual["resources"].(map[string]any)[field] = 1 - case "image": - actual["image"] = "runtime:latest" - case "root": - actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = 16384 - case "environment": - actual["mounts"].([]any)[0].(map[string]any)["storage"].(map[string]any)["capacity_mib"] = 8192 - case "extra_mount": - actual["mounts"] = append(actual["mounts"].([]any), map[string]any{"type": "Bind", "guest": "/other"}) - case "missing_mount": - delete(actual, "mounts") - } - raw, _ := json.Marshal(actual) - err := qualifyConfiguration(config, wire.Compute{}, string(raw), false) - if (field == "match" && err != nil) || (field != "match" && !errors.Is(err, sandbox.ErrInvalid)) { - t.Fatalf("configuration=%s error=%v", raw, err) - } - }) - } -} - -func TestRestoredRootRequiresVerifiedInheritedProof(t *testing.T) { - config, actual := deploymentFixture() - actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = nil - compute := wire.Compute{RestoredFrom: &wire.SnapshotIdentity{ID: "verified-parent"}} - for _, proof := range []string{"", "foreign", resourceProof(config)} { - actual["labels"] = map[string]string{resourceProofLabel: proof} - raw, _ := json.Marshal(actual) - err := qualifyConfiguration(config, compute, string(raw), false) - if (proof == resourceProof(config)) != (err == nil) { - t.Fatalf("proof=%s error=%v", proof, err) - } - if err = qualifyConfiguration(config, wire.Compute{}, string(raw), true); err == nil { - t.Fatal("initial root accepted missing size") - } - if err = qualifyConfiguration(config, compute, string(raw), true); err != nil { - t.Fatal("verified snapshot cannot qualify restored root", err) - } - } -} - -func TestSnapshotProofBindsResourcesWithoutChangingCleanupOwnership(t *testing.T) { - config, actual := deploymentFixture() - ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"} - labels := wire.Labels(config, ref) - labels[resourceProofLabel] = resourceProof(config) - if err := qualifySnapshotResources(config, labels); err != nil { - t.Fatal(err) - } - config.RootDiskMiB++ - if err := qualifySnapshotResources(config, labels); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("snapshot accepted different bound", err) - } - actual["labels"] = labels - raw, _ := json.Marshal(actual) - if _, err := qualifyCompute(config, ref, wire.Compute{ID: "owned"}, "owned", "running", string(raw)); err != nil { - t.Fatal("resource drift changed cleanup ownership", err) - } - if err := qualifySnapshotResources(config, nil); !errors.Is(err, sandbox.ErrInvalid) { - t.Fatal("missing snapshot proof accepted", err) - } -} diff --git a/services/agents-api/tools/microsandbox-provider/go.mod b/services/agents-api/tools/microsandbox-provider/go.mod deleted file mode 100644 index 8830d39ab..000000000 --- a/services/agents-api/tools/microsandbox-provider/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module github.com/MiniMax-AI-Dev/parsar/services/agents-api/tools/microsandbox-provider - -go 1.26.8 - -require ( - github.com/MiniMax-AI-Dev/parsar v0.0.0 - github.com/superradcompany/microsandbox/sdk/go v0.7.2 -) - -require ( - github.com/google/uuid v1.6.0 // indirect - golang.org/x/sync v0.22.0 // indirect -) - -// The helper ships from this repository; the SDK itself is a released module. -replace github.com/MiniMax-AI-Dev/parsar => ../../../.. diff --git a/services/agents-api/tools/microsandbox-provider/main.go b/services/agents-api/tools/microsandbox-provider/main.go deleted file mode 100644 index 67cfc19e3..000000000 --- a/services/agents-api/tools/microsandbox-provider/main.go +++ /dev/null @@ -1,266 +0,0 @@ -//go:build linux - -// The helper performs one finite Provider operation. Only Core owns durable -// lifecycle intent, allocation generations, scheduling and recovery policy. -package main - -import ( - "context" - "crypto/sha256" - "debug/elf" - "encoding/hex" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "runtime/debug" - "strings" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" - sdk "github.com/superradcompany/microsandbox/sdk/go" -) - -func main() { - // The inherited node generation lease covers this helper's actual lifetime. - // Set CLOEXEC before SDK initialization or any possible subprocess spawn so - // long-lived VMs and host daemons cannot inherit a local helper reference. - if os.Getenv("OAC_NODE_GENERATION_LEASE_FD") == "3" { - syscall.CloseOnExec(3) - } - response := serve(os.Stdin) - // Errors and upstream diagnostics can contain secrets: expose only stable codes. - _ = json.NewEncoder(os.Stdout).Encode(response) -} -func serve(input io.Reader) wire.Response { - out := wire.Response{Version: wire.ProtocolVersion} - decoder := json.NewDecoder(io.LimitReader(input, wire.MaxRequestBytes+1)) - decoder.DisallowUnknownFields() - var q wire.Request - if decoder.Decode(&q) != nil || wire.ValidateRequest(q) != nil { - out.ErrorCode = "invalid" - return out - } - var extra any - if decoder.Decode(&extra) != io.EOF { - out.ErrorCode = "invalid" - return out - } - if err := checkInstallation(q.Config); err != nil { - out.ErrorCode = "unconfirmed" - return out - } - release, err := allocationLock(q) - if err != nil { - out.ErrorCode = "unconfirmed" - return out - } - defer release() - // Lifecycle calls deliberately retain the flock until the SDK has settled. - // Cancelling an FFI wait does not prove the underlying operation stopped. - b := backend{q: q} - out, err = b.run(context.Background()) - out.Version = wire.ProtocolVersion - if err != nil { - out.ErrorCode = code(err) - } - return out -} -func code(err error) string { - switch { - case errors.Is(err, sandbox.ErrInvalid): - return "invalid" - case errors.Is(err, sandbox.ErrOwnership), sdk.IsKind(err, sdk.ErrSandboxReplaced): - return "ownership" - case errors.Is(err, sandbox.ErrExists), sdk.IsKind(err, sdk.ErrSandboxAlreadyExists): - return "exists" - case errors.Is(err, sandbox.ErrNotFound), sdk.IsKind(err, sdk.ErrSandboxNotFound): - return "not_found" - case errors.Is(err, sandbox.ErrCommandUnconfirmed): - return "command_unconfirmed" - case errors.Is(err, runtimeobs.ErrUnavailable), sdk.IsKind(err, sdk.ErrMetricsDisabled), sdk.IsKind(err, sdk.ErrMetricsUnavailable): - return "metrics_unavailable" - default: - return "unconfirmed" - } -} -func allocationLock(q wire.Request) (func(), error) { - dir := filepath.Join(q.Config.RuntimeHome, "oac-locks") - if e := os.MkdirAll(dir, 0700); e != nil { - return nil, e - } - st, e := os.Lstat(dir) - if e != nil { - return nil, e - } - if !st.IsDir() || st.Mode().Perm() != 0700 { - return nil, sandbox.ErrOwnership - } - name := filepath.Join(dir, wire.Name(q.Config, q.Reference, 0)+".lock") - fd, e := syscall.Open(name, syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) - if e != nil { - return nil, e - } - f := os.NewFile(uintptr(fd), name) - for { - if !time.Now().Before(q.Deadline) { - f.Close() - return nil, context.DeadlineExceeded - } - e = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) - if e == nil { - return func() { _ = syscall.Flock(fd, syscall.LOCK_UN); _ = f.Close() }, nil - } - if e != syscall.EWOULDBLOCK && e != syscall.EAGAIN { - f.Close() - return nil, e - } - time.Sleep(20 * time.Millisecond) - } -} -func checkInstallation(c wire.Config) error { - build, ok := debug.ReadBuildInfo() - if !ok { - return sandbox.ErrInvalid - } - matched := false - for _, d := range build.Deps { - if d.Path == "github.com/superradcompany/microsandbox/sdk/go" { - matched = d.Version == wire.SDKVersion && d.Replace == nil - } - } - if !matched { - return sandbox.ErrInvalid - } - for _, v := range []struct{ path, hash string }{{c.RuntimePath, c.RuntimeSHA256}, {c.FirmwarePath, c.FirmwareSHA256}} { - f, e := os.Open(v.path) - if e != nil { - return e - } - h := sha256.New() - _, e = io.Copy(h, f) - _ = f.Close() - if e != nil || hex.EncodeToString(h.Sum(nil)) != v.hash { - return sandbox.ErrInvalid - } - } - if e := runtimeVersion(c.RuntimePath); e != nil { - return e - } - if e := os.MkdirAll(c.RuntimeHome, 0700); e != nil { - return e - } - st, e := os.Lstat(c.RuntimeHome) - if e != nil { - return e - } - if !st.IsDir() || st.Mode().Perm() != 0700 { - return sandbox.ErrOwnership - } - // Explicit paths and a local backend avoid ambient cloud/profile selection. - for _, v := range wire.HelperEnvironment(nil, c) { - key, value, ok := cutEnv(v) - if ok { - if e := os.Setenv(key, value); e != nil { - return e - } - } - } - configPath, e := isolatedConfig(c.RuntimeHome) - if e != nil { - return e - } - if e := os.Setenv("MSB_CONFIG_PATH", configPath); e != nil { - return e - } - runtime, e := sdk.ResolveRuntime(sdk.RuntimeConfig{Home: c.RuntimeHome, MSBPath: c.RuntimePath, LibkrunfwPath: c.FirmwarePath}) - if e != nil { - return e - } - if runtime.MSBPath != c.RuntimePath || runtime.LibkrunfwPath != c.FirmwarePath { - return sandbox.ErrOwnership - } - selected, e := sdk.DefaultBackendInfo() - if e != nil { - return e - } - if selected.Kind != sdk.BackendLocal { - return sandbox.ErrOwnership - } - return nil -} -func cutEnv(s string) (string, string, bool) { - for i := range s { - if s[i] == '=' { - return s[:i], s[i+1:], true - } - } - return "", "", false -} - -func runtimeVersion(path string) error { - binary, e := elf.Open(path) - if e != nil { - return e - } - defer binary.Close() - section := binary.Section(".msbver") - if section == nil { - return sandbox.ErrInvalid - } - version, e := section.Data() - if e != nil { - return e - } - if strings.TrimRight(string(version), "\x00") != strings.TrimPrefix(wire.SDKVersion, "v") { - return sandbox.ErrInvalid - } - return nil -} - -// An empty JSON object prevents ambient SDK profiles without invalid empty input. -func isolatedConfig(home string) (string, error) { - path := filepath.Join(home, "oac-sdk-config.json") - fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW, 0600) - if err == nil { - f := os.NewFile(uintptr(fd), path) - _, err = f.WriteString("{}\n") - if err == nil { - err = f.Sync() - } - closeErr := f.Close() - if err != nil { - return "", err - } - if closeErr != nil { - return "", closeErr - } - } else if !os.IsExist(err) { - return "", err - } - fd, err = syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0) - if err != nil { - return "", err - } - f := os.NewFile(uintptr(fd), path) - defer f.Close() - st, err := f.Stat() - if err != nil { - return "", err - } - if !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { - return "", sandbox.ErrOwnership - } - data, err := io.ReadAll(io.LimitReader(f, 4)) - if err != nil { - return "", err - } - if string(data) != "{}\n" { - return "", sandbox.ErrOwnership - } - return path, nil -} diff --git a/services/core-console/config.go b/services/core-console/config.go deleted file mode 100644 index 18d0887af..000000000 --- a/services/core-console/config.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "io" - "net/url" - "os" - "path/filepath" - "strings" - "unicode" - "unicode/utf8" -) - -type config struct { - addr, origin, dist string - coreKey, nodePayloadDir string - upstream *url.URL - installationSocket string - bootstrap bool -} - -func loadConfig() (config, error) { - var problems, renamed []string - for _, setting := range [][2]string{ - {"CORE_CONSOLE_ADDR", "OAC_WEB_ADDR"}, - {"CORE_CONSOLE_ORIGIN", "OAC_WEB_ORIGIN"}, - {"CORE_CONSOLE_DIST", "OAC_WEB_DIST"}, - {"CORE_CONSOLE_UPSTREAM", "OAC_WEB_UPSTREAM"}, - {"CORE_CONSOLE_CORE_KEY_FILE", "OAC_WEB_CORE_KEY_FILE"}, - {"CORE_CONSOLE_NODE_PAYLOAD_DIR", "OAC_WEB_NODE_PAYLOAD_DIR"}, - } { - if _, present := os.LookupEnv(setting[0]); present { - renamed = append(renamed, setting[0]+" → "+setting[1]) - } - } - renamed = append(renamed, log.RenamedEnvironment()...) - if len(renamed) > 0 { - problems = append(problems, "OpenAgentCore renamed these Web settings; set the new names and remove the old ones: "+strings.Join(renamed, ", ")) - } - if _, present := os.LookupEnv("CORE_CONSOLE_ADMIN_TOKEN_FILE"); present { - problems = append(problems, "CORE_CONSOLE_ADMIN_TOKEN_FILE was renamed; set OAC_WEB_CORE_KEY_FILE to the Core key file instead") - } - for _, retired := range []string{"CORE_CONSOLE_AUTH_MODE", "CORE_CONSOLE_STATE_DIR", "CORE_CONSOLE_PASSWORD_FILE"} { - if _, present := os.LookupEnv(retired); present { - problems = append(problems, retired+" is retired; Web signs in with the Core key only, so remove this setting") - } - } - if len(problems) > 0 { - return config{}, errors.New(strings.Join(problems, "; ")) - } - c := config{ - addr: envDefault("OAC_WEB_ADDR", ":8080"), - origin: envDefault("OAC_WEB_ORIGIN", "http://127.0.0.1:8080"), - dist: envDefault("OAC_WEB_DIST", "/www"), - } - origin, err := serverURL(c.origin) - if err != nil || origin.Path != "" { - return config{}, errors.New("OAC_WEB_ORIGIN must be an HTTP(S) origin without a path") - } - c.upstream, err = serverURL(envDefault("OAC_WEB_UPSTREAM", "http://core:8091")) - if err != nil { - return config{}, errors.New("OAC_WEB_UPSTREAM must be an HTTP(S) server URL without credentials, query or path") - } - if !filepath.IsAbs(c.dist) { - return config{}, errors.New("OAC_WEB_DIST must be absolute") - } - c.coreKey, err = readSecret(envDefault("OAC_WEB_CORE_KEY_FILE", "/admin/core.key")) - if err != nil { - return config{}, errors.New("OAC_WEB_CORE_KEY_FILE must name a private regular file containing the Core key") - } - if utf8.RuneCountInString(c.coreKey) < minimumCoreKeyLength { - return config{}, errors.New("the Core key in OAC_WEB_CORE_KEY_FILE must have at least 32 characters") - } - c.nodePayloadDir = os.Getenv("OAC_WEB_NODE_PAYLOAD_DIR") - if c.nodePayloadDir != "" && !filepath.IsAbs(c.nodePayloadDir) { - return config{}, errors.New("OAC_WEB_NODE_PAYLOAD_DIR must be absolute") - } - c.installationSocket = os.Getenv("OAC_WEB_INSTALLATION_SOCKET") - if c.installationSocket != "" && !filepath.IsAbs(c.installationSocket) { - return config{}, errors.New("OAC_WEB_INSTALLATION_SOCKET must be absolute") - } - bootstrap := envDefault("OAC_WEB_BOOTSTRAP", "0") - if bootstrap != "0" && bootstrap != "1" { - return config{}, errors.New("OAC_WEB_BOOTSTRAP must be 0 or 1") - } - c.bootstrap = bootstrap == "1" - if c.bootstrap && (origin.Scheme != "http" || c.installationSocket == "") { - return config{}, errors.New("HTTP bootstrap requires installation management and an HTTP origin") - } - return c, nil -} - -func envDefault(key, fallback string) string { - if value, exists := os.LookupEnv(key); exists { - return value - } - return fallback -} - -func serverURL(value string) (*url.URL, error) { - u, err := url.Parse(value) - if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || - u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || - strings.ContainsAny(value, "?#") || (u.Path != "" && u.Path != "/") || u.RawPath != "" { - return nil, errors.New("invalid server URL") - } - return u, nil -} - -func readSecret(name string) (string, error) { - if !filepath.IsAbs(name) { - return "", errors.New("secret path must be absolute") - } - f, err := os.Open(name) - if err != nil { - return "", err - } - defer f.Close() - info, err := f.Stat() - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { - return "", errors.New("secret file must be private and regular") - } - data, err := io.ReadAll(io.LimitReader(f, 4097)) - if err != nil || len(data) > 4096 { - return "", errors.New("invalid secret size") - } - value := strings.TrimSpace(string(data)) - if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\x00\r\n") { - return "", errors.New("invalid secret") - } - return value, nil -} diff --git a/services/core-console/main.go b/services/core-console/main.go deleted file mode 100644 index 2ba7b2e70..000000000 --- a/services/core-console/main.go +++ /dev/null @@ -1,53 +0,0 @@ -// Command oac-web serves the Core Web build and its authenticated API proxy. -package main - -import ( - "context" - "errors" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" -) - -func main() { - if err := run(); err != nil { - log.Bg().Error("Core console stopped", "error", err) - os.Exit(1) - } -} - -func run() error { - log.Init(log.ConfigFromEnv()) - c, err := loadConfig() - if err != nil { - return err - } - handler, err := newConsole(c) - if err != nil { - return err - } - defer handler.Close() - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - server := &http.Server{Addr: c.addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} - done := make(chan error, 1) - go func() { done <- server.ListenAndServe() }() - select { - case err := <-done: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return errors.New("console listener failed") - case <-ctx.Done(): - shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := server.Shutdown(shutdown); err != nil { - return server.Close() - } - return nil - } -} diff --git a/services/agents-api/Dockerfile b/services/core/Dockerfile similarity index 90% rename from services/agents-api/Dockerfile rename to services/core/Dockerfile index 1d415a455..3a6607367 100644 --- a/services/agents-api/Dockerfile +++ b/services/core/Dockerfile @@ -1,4 +1,4 @@ -# Linux amd64 runtime. Build with make docker-build-agents-api. +# Linux amd64 runtime. Build with make docker-build-core. # The context contains independently built executables and the pinned cloud SDK helper. FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libgcc-s1 \ diff --git a/services/agents-api/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md similarity index 99% rename from services/agents-api/IMPLEMENTATION.md rename to services/core/IMPLEMENTATION.md index b2f254778..e912c7246 100644 --- a/services/agents-api/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Agents API implementation constraints -These rules describe how the Core service (`services/agents-api`) currently +These rules describe how the Core service (`services/core`) currently implements the public, administrator and machine contracts. They are contributor constraints, not user documentation. Wire behavior and qualification evidence stay in the linked [contracts](../../contracts/agents-api/README.md); Runtime @@ -261,7 +261,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Do not deploy a pre-deletion service against a database with deletion markers; migration rollback refuses to remove the column while deleted records exist, preventing public resurrection. -- `services/agents-api` owns its SQL schema, sqlc queries and embedded goose +- `services/core` owns its SQL schema, sqlc queries and embedded goose migrations. `OAC_DATABASE_URL` is required; never fall back to the product database URL. It stores tenant-scoped Sessions with a stable engine and durable creation retry identity. @@ -317,7 +317,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Never reuse product master-key conventions or daemon transport encryption for this storage boundary. Missing key configuration disables credential writes; malformed explicit configuration fails startup. See - [`services/agents-api/credentials.md`](credentials.md) for + [`services/core/credentials.md`](credentials.md) for key persistence and current limits. Storage-key rotation remains separate work; resource creation never contacts the destination. - `GET /v1/vaults/{vault_id}/credentials` lists safe metadata only, with both @@ -653,7 +653,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Hosted empty-filter, mismatched-filter cursor and exact error semantics remain unverified. Other resource lists do not accept this parameter. - `make sqlc-generate` and the drift gate cover this service. Run - `make check-agents-api` with `OAC_TEST_DATABASE_URL` pointing to a + `make check-core` with `OAC_TEST_DATABASE_URL` pointing to a dedicated `oac_*_tests` database for Session integration tests. CI provides a separate PostgreSQL service. Migration immutability and ordering apply independently to each service directory. @@ -715,7 +715,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti than reimplementing transport or copying wire types. Supply an explicit service base URL/key and creation retry key; SDK retries are disabled by default. Product integration is a later cutover, not a side effect of constructing this client. -- `services/agents-api/tests/official_client.py` verifies the actual server with +- `services/core/tests/official_client.py` verifies the actual server with the pinned SDK and strict response validation. It requires a dedicated test DB prepared by the Store tests and `OAC_TEST_SERVER_BIN`; it never starts Docker. The same harness runs the official Go client with fresh execution tenants and @@ -734,7 +734,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti connections and binding reads; an existing connection closes on its next heartbeat. Connectivity comes from the live registry, not a persisted online flag. `last_seen_at` is diagnostic only. Product gateway behavior is unchanged. -- `services/agents-api/internal/execution` dispatches internally resolved Turns +- `services/core/internal/execution` dispatches internally resolved Turns through that gateway. Claim `queued` to `in_progress` before subscribing/sending; never automatically replay a claimed or interrupted Turn. Ordered extra inputs require native steering receipts. Commit terminal outcome and native Session ID @@ -1456,7 +1456,7 @@ change guard. This boundary does not add cross-node Session migration, Core multi-active, autoscaling, Kubernetes or harness residency. The common -`services/agents-api/internal/sandbox` contract owns the five required operations and optional capabilities documented +`services/core/internal/sandbox` contract owns the five required operations and optional capabilities documented in [the Provider guide](../../docs/sandbox-provider.md). Core orchestration must not import an adapter or SDK. Exact compute identity, generation construction, inspection, full snapshot capture, restore, thaw and owned artifact cleanup use that common capability. Initialization and diff --git a/services/core/README.md b/services/core/README.md new file mode 100644 index 000000000..cdd982cc2 --- /dev/null +++ b/services/core/README.md @@ -0,0 +1,685 @@ +# Agents API + +See [Configuration](../../docs/configuration.md) for process, deployment, node and daemon configuration ownership. + +Independent execution service implementing part of the pinned OpenAI Agents API. +It owns reusable Agents, durable Sessions/Turns/Items, live events, function actions +and a daemon execution worker. Public execution supports qualified Codex, Claude Code +(`claude_sdk`) and MiniMax Code (`mcode`) profiles through the shared Runtime contract. +The three-harness Linux amd64 Docker V1 MVP has accepted evidence. V1 user-managed +Runtime enrollment has [recorded real acceptance](../../contracts/agents-api/user-managed-runtime-v1.md) +with explicit deployment coverage. [E2B](deploy/e2b/README.md) can back Core-managed +hosted sandboxes, selected in Web's sandbox setup, or application-managed +`self_hosted` Environments. +It builds and runs with its own PostgreSQL database and credentials; +Parsar's product service, frontend and database are not required. + +Use this guide to build, configure and connect a client. The +[protocol coverage](../../contracts/agents-api/README.md) lists supported operations, +engine limits, acceptance evidence and missing resources. The target remains the +complete pinned protocol; current workflows do not establish full compatibility. +Parsar product execution and its eventual public-client cutover are separate. + +## Reusable Agents + +Static-bearer and OAuth Vault Credentials support creation, replacement, deletion +and safe metadata retrieval/listing. Vault deletion atomically removes its +Credentials. Configure their independent encryption key and authenticated Session +use through the [credential guide](credentials.md); see [OAuth credentials](oauth-credentials.md) +for application authorization, dispatch-time refresh and revocation boundaries. + +The pinned Python client saves an Agent independently, then starts a Session with initial input: + +```python +agent = client.beta.agents.create(model="your-model", name="Example") +session = client.beta.agents.sessions.create( + agent_id=agent.id, environment={"type": "none"}, input="Hello", +) +``` + +These resources belong to the authenticated execution tenant. Saving configuration +does not launch an engine. Optional Session `agent` fields override the saved +configuration: omitted fields inherit, supplied objects/arrays replace whole fields. +Source and Session metadata stay separate; execution never looks up the source again. + +- Retrieve with `client.beta.agents.retrieve(agent.id)`; list saved resources with + `client.beta.agents.list(limit=20, order="desc")` and SDK auto-pagination. +- Update with `client.beta.agents.update(agent.id, instructions="New instructions")`. + Omitted fields remain unchanged. Metadata replaces all pairs; null/empty clears it. + Existing Sessions retain their configuration; new Sessions resolve the update. +- Delete with `client.beta.agents.delete(agent.id)`. Existing Sessions and history + remain available. New references fail; recorded creation retries recover their + accepted snapshot without consulting the deleted source. +- List Sessions with `client.beta.agents.sessions.list(agent_id=agent.id)`. Filtering + uses the immutable root ID, including inline Agents and history after source changes. + +See the [configuration and retry limits](../../contracts/agents-api/README.md#public-semantics) +before relying on optional settings or hosted error/default equivalence. + +## Build standalone binaries + +```bash +make build-core +# Optional absolute output directory: +OAC_DEV_CORE_BUILD_DIR="$HOME/.oac/build/oac-core-test" make build-core +``` + +The default output is `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core`: + +- `oac-core`: HTTP service and execution worker. +- `oac-core-migrate`: this service's embedded database migrations. +- `oac-core-device`: operator device provisioning and revocation. +- `oac-core-environment-key`: principal executor key issuance, rotation and revocation. + +Use these executables in place of the corresponding `go run` commands below. +The build needs Go and access to its pinned module dependencies; it does not need +Node, Docker, the product service or frontend. An isolated source context enforces +that boundary on every build. [Contributor rules](../../docs/maintainers.md#standalone-core-builds) +define the allowed shared packages and required checks. Runtime database/key +configuration and a separately installed execution daemon are still required; +these binaries do not establish full protocol coverage. For a standalone Linux +container, see [Run Core without the installer](../../docs/maintainers.md#run-core-without-the-installer). + +`make build-core-release` packages these commands and `oac-node` in a versioned +Linux amd64 archive, with source/protocol identity, checksums, a license and +[operator instructions](RELEASE.md). Build from a clean Git worktree with Go and +Python 3.9+; output defaults to `~/.oac/build/oac-core-release` (or +`OAC_DEV_RELEASE_DIR`). The extracted API needs no source checkout or compiler. +The archive and the container are advanced paths for running Core alone; see +[Maintainers and advanced deployments](../../docs/maintainers.md). The Docker-hosted +archive variant (`AGENTS_API_RELEASE_RUNTIME_IMAGE`) is retired: it recorded only an +image ID, not the complete Runtime release a Docker deployment requires. Docker-hosted +deployments use the Core distribution and its +[installer](../../docs/getting-started/install.md), whose manifest carries the complete +release; Docker nodes are added from Web. + +## Database ownership + +Use a dedicated PostgreSQL database and account, separate from the Parsar product. +This service does not import `server/internal` or apply product migrations. +Migrations are embedded and tracked in `agents_api_schema_version`. + +```bash +OAC_DATABASE_URL='postgres://.../agents_api' \ + go run ./services/core/cmd/migrate +``` + +The public `Idempotency-Key` creation header is optional: omission creates a new +Session. A supplied key identifies the request within its authenticated tenant. +Inline retries use normalized effective configuration; new saved-Agent references +record caller intent independently of later source updates/deletion. Retries do +not admit initial input again. Every retry must match the original typed creator, +including across key rotation. Keys in the same Project share the creator principal, +so rotating a key preserves that retry identity. Records with a known creator but +no recorded request intent retain resolved-snapshot behavior; records without a creator cannot be retried. +These retry policies are not verified hosted semantics. See the +[retry boundary](../../contracts/agents-api/README.md#public-semantics). + +The Store uses internal creation keys and preserves immutable engine/configuration, +native continuity and same-tenant device bindings. The public API applies schema +validation/defaults before storage. Internal bounds are 64 KiB for metadata and +512 KiB for configuration. Keep credentials out of both. Public metadata permits +at most 16 string pairs, 64-character keys and 512-character values; storage bounds +do not replace those rules. Violations and non-string values return +`invalid_request_error` with a `metadata` or `metadata.` param. PostgreSQL +cannot store U+0000, so requests containing it in any stored string return 400 +before anything is written; this is a local limit, not hosted parity. Tenant identity comes from authenticated credentials, +never metadata or a caller-supplied business identity. + +## Internal Turn persistence + +Validated message/cancel/function-result batches commit under a tenant-scoped +Session lock. Messages start a Turn when idle and steer active work. Retry keys +identify the whole ordered batch; an invalid event does not partially admit it. +Queued cancellation needs no live engine. Active cancellation awaits a native +outcome, and completion may win the race. Terminal states cannot be overwritten. + +A Session keeps its effective configuration, engine and device across Turns; +product Conversations, native Sessions, connections, processes and sandboxes are +different objects. Strict native resume requires retained history on that device. +The API owns durable public history and pending function decisions; adapters own +native translation and their harness owns the model/tool loop. + +The worker uses its database lease connection for execution writes. Lease loss +fences those writes; it does not prove native commands or side effects have stopped. +Restart conservatively fails previously claimed work and retains queued work. +Uncertain delivery is never blindly replayed. Function actions and live SSE are +available within the [current coverage](../../contracts/agents-api/README.md); +other pending interactions, process-loss recovery and environment lifecycle remain +incomplete. Durable acceptance is not an exactly-once side-effect guarantee. + +## Standalone HTTP service + +Run migrations first, then `go run ./services/core/cmd/server`. The service +uses `OAC_DATABASE_URL` for its dedicated database; it does not read the +product database or accept product login cookies. It requires the Core key digest +file named by `OAC_CORE_KEY_DIGESTS_FILE` at startup; the Core key +authenticates `/core/v1`, where Projects and keys are managed. The Core key cannot +authenticate `/v1`, and application API keys cannot authenticate `/core/v1`. + +Projects and API keys live only in the database. Configuration files contain +infrastructure settings and deployment credentials, not business identities. +Installation creates no Project or application key. Using the +[administrator API](../../contracts/agents-api/admin-api.md), create a Project with +`POST /core/v1/projects` and issue a named key with +`POST /core/v1/projects/{project_id}/keys`, or use Core Web's **Projects and +keys** page. Both requests accept a JSON object containing `name`; Core generates +the identifiers. + +A Project owns one tenant and one execution principal. All keys in it have equal +access to its assets and share that principal; write provenance records the actual +key separately. Issuance returns plaintext once, and the database stores its digest. +Deliver the secret only to authorized applications. Rotate by issuing another key +in the same Project and revoking the old one. No secret-reset endpoint or service +restart is needed. Renaming a Project preserves its ID, principal and assets. +Archiving disables all its keys but retains assets and already accepted execution. +Administrators can read or delete retained resources. + +Optional `OpenAI-Organization` and `OpenAI-Project` headers must match the Project's +execution scope; repeated or conflicting values fail authentication. The catalog +Project UUID and its external execution-scope identifier are distinct; see the +administrator contract. These identities grant no product-user rights. New Sessions +persist the Project principal as creator atomically and never change it on retry. +Historical Sessions keep unknown creators and remain readable; no key, metadata or +product record can assign their ownership through a retry. Retire older API writers +before starting this deployment; mixed-version creation is unsupported. Executor +credentials separately match this recorded creator before authorizing an Environment +connection; they do not inherit general caller API permissions. Native Runtime and +node transport contracts are unchanged. + +`OAC_ADDR` defaults to `127.0.0.1:8091`; use a TLS reverse proxy for remote +access. `OAC_DEFAULT_HARNESS` defaults to `codex`; use `claude_sdk` for Claude Code +or `mcode` for MiniMax Code. Configure the corresponding qualified Runtime through +its [deployment guide](../../contracts/agents-api/README.md#public-engine-profiles). +It selects new Sessions independently of the requested +model. Existing Sessions retain their stored engine. Set +`OAC_HARNESSES=codex,claude_sdk,mcode` to explicitly enable installed profiles +for user-managed enrollment without a managed Provider. This list supplements the +default engine and any managed engine profiles; unknown names fail startup. +Enabling a profile does not install its harness or qualify its deployment. + +The SDK base URL is `http://127.0.0.1:8091/v1`. Requests require a bearer key. +Agents and Vault routes also require `OpenAI-Beta: agents=v1` (set by their SDK +resources); general Files routes do not. Supported operations include: + +- Saved Agent create/retrieve/update/list/delete. +- Session create/retrieve/list/delete and metadata-only update. Creation supports inline + configuration or a saved `agent_id`, field replacements, initial text + and ordinary or streaming responses. Initial input is required for `none` and + streamed creation outside `self_hosted`. +- Session event submission and live streaming, Turn retrieve/list and Items list. +- Environment retrieve for three-harness colocated self-hosted and Docker + profiles, bounded live file listing, and inline/source copies into qualified + local workspaces. Shared Artifacts support capture, list/retrieve/content and + deletion independently of the live Runtime after publication. +- Project-owned `user_data` source file upload/list, metadata/content retrieval and + deletion; see [source Files](../../contracts/agents-api/source-files.md). +- Vault create/retrieve/list/delete, project-scoped pagination and stored status + filtering; static-bearer and OAuth Credential create/retrieve/list/replacement/delete, + plus [dispatch-time OAuth refresh](oauth-credentials.md). Public archive semantics remain gaps. Already-delivered credentials + are not withdrawn by local deletion. Session attachments support + [authenticated HTTPS MCP](credentials.md#use-a-credential-in-a-session). + +Execution uses the selected +[engine profile](../../contracts/agents-api/README.md#public-engine-profiles), +including `none` and the colocated self-hosted profile described below. +Ordinary JSON requests have a 1 MiB body limit; file transfers use the separate +bounds in the Files contracts. Session lists support `after`, `limit` (0 is treated +as 1 and values above 100 as 100), +`order` (`asc`/`desc`) and optional immutable root `agent_id`. The local defaults +are 20 and descending order; exact hosted limits/error semantics remain unverified. +Session updates require the metadata field; null/empty clears it and an object +replaces supplied pairs. An empty update body rejects before resource lookup. + +Delete with `client.beta.agents.sessions.delete(session.id)`. Only a durably idle +or failed Session without required actions or pending input can be deleted; a +queued, running or waiting root Turn or a pending input reservation returns 409 +`conflict_error` and leaves the Session unchanged. Subagent child Turns and +pending Environment file writes do not block deletion. Cancel its work +with an `agent.session.input.cancel` event, wait until it is idle, then delete it. +Confirmation means public removal: Session/history reads and new input become +unavailable, and existing streams close on observing removal. Creation keys stay +reserved; deletion never affects other Sessions, saved Agents or their shared +device. Internal records are retained for execution settlement. Managed Docker +deletion separately revokes authority and reclaims owned compute/workspace/history; +caller-managed compute is not reclaimed by this service. Repeating the deletion of +your own deleted Session returns the same confirmation, missing and foreign +Sessions return 404, and creation-key reuse returns 409; overlapping stream timing +is unverified. + +Codex command Items support live `agent.output.command_execution_output.delta` +events when emitted by the connected daemon. Queries retain accumulated drafts and +authoritative completion snapshots, including observed partial output after +cancellation. Native text conversion/output quotas apply; older peers may provide +only completion snapshots. Pinned native 0.153.4 may also omit early process +output from both notifications and its final aggregate; this remains an upstream +execution gap. Recover missed output with Items queries, not SSE replay. + +Non-text message input, Subagents and installations beyond hosted initial files, +env, ordered setup and npm/Python packages remain unsupported. Saving optional Agent configuration does not make +it executable. Unsupported requests fail explicitly. `/healthz` reports liveness only. + +## Managed hosted execution + +The basic `openai_hosted` profiles for Codex, Claude Code and MiniMax Code require +explicit operator configuration. Select the qualified native image using the +[engine profile guides](../../contracts/agents-api/README.md#public-engine-profiles), +then follow the [Docker setup](deploy/codex/README.md#standalone-operator-configuration). +Core-managed hosting supports deployment-selected E2B, Docker or microsandbox; +see the [nodes guide](../../docs/getting-started/nodes.md). For the separate +user-managed E2B path, see +[E2B Runtime packaging](deploy/e2b/README.md). +Core remains independently deployed with its own database. Public idle and initial +text Sessions share the existing preparation, execution, Files and recovery paths. +Networking defaults to enabled; disabled and exact-domain restricted policy are +supported after setup. System/npm/Python packages use the shared initializer; +remaining unsupported combinations are explicit gaps. Initial inline/file_id files, confidential env, npm/Python packages, +ordered setup and [public Environment Templates](../../contracts/agents-api/environment-templates.md) +resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses +require separate integration and qualification. +Connected describes the authenticated Runtime connection, not native readiness. +A provisioning failure fails the Session with a safe step and exit-status reason +([initialization failure](../../contracts/agents-api/environment-templates.md#initialization-failure--september-23)); +other exact hosted failure and expiry semantics remain unverified. + +The independent Docker Provider consumes an immutable Runtime image and retains +one caller-owned allocation reference through partial creation and cleanup. Persist +that reference before Create and serialize its lifecycle; resolve a lost response +with observed state, without rewriting bootstrap credentials or replaying startup. +Provider state is compute state, not public Environment readiness. Named Runtime +volumes need explicit owned cleanup after container removal. A second mount of the +same workspace volume subdirectory provides the public `/workspace` path to native +tools; trusted staging and atomic rename retain the original parent mount. Do not +copy files or widen private-path reads to preserve an alias. Initialization command +timeouts can leave processes alive and require allocation cleanup before reuse. +The [managed Runtime build and operator configuration](deploy/codex/README.md#managed-runtime-image-and-docker-adapter) +defines the explicit opt-in for basic hosted admission. Building an image alone +does not qualify its isolation or enable public creation. + +## Internal execution device connection + +The standalone service can accept existing daemon connections without a Parsar +workspace or product database. Enable its internal gateway by setting +`OAC_PUBLIC_URL=https://your-service` (HTTP only for a loopback host during +local development). Core returns the derived +`wss://your-service/api/v1/agent-daemon/ws` as `self_hosted.remote_url`. It names +our private daemon transport, not stock OpenAI `exec-server` interoperability. + +After migrations, an operator can provision a device for an execution tenant: + +```bash +umask 077 +mkdir -p ~/.oac/daemon/default +go run ./services/core/cmd/device \ + --tenant '' --name 'local executor' \ + --url 'http://127.0.0.1:8091' \ + > ~/.oac/daemon/default/auth.json +oac-daemon connect --profile default +``` + +The command requires `OAC_DATABASE_URL` and emits a secret profile once. +Use a new profile rather than overwriting an existing device's credentials. When +provisioning remote compute, securely transfer this file to the same profile path +on the executor. The database stores only the credential digest. API keys and +device credentials are not interchangeable. To revoke a device: + +```bash +go run ./services/core/cmd/device \ + --tenant '' --revoke '' +``` + +An existing connection is retired on its next heartbeat; new connections are +rejected immediately. The internal Store binds each Session to one same-tenant +device, preserves that assignment across retries/restarts, and refuses a silent +move to another device. Revoked bindings cannot be used for dispatch. Device +connections alone do not start a Turn. Submit text, cancellation or function results +through the official Session events endpoint; the worker assigns a same-tenant host and preserves that +binding. Managed Docker has three-harness evidence. This generic device provisioning path +is for `none`; self-hosted Sessions require the dedicated enrollment below. +User-managed enrollment has a separate [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md); complete protocol semantics remain partial. See the [ownership rules](../../AGENTS.md#public-api). + +### Enable Claude SDK execution + +Build and extract the runtime archive into a fresh managed directory on a matching +executor host. The archive contains the compiled bridge and pinned production +SDK/MCP/native dependencies; Node is installed separately. Linux x64/glibc with +Node22 is the accepted platform. See the +[runtime artifact contract](../../packages/claude-sdk-adapter/README.md#runtime-artifact) +for build outputs, version checks and platform restrictions. + +```bash +make build-claude-sdk-runtime +# After extracting the matching archive into this operator-chosen directory: +export OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT="$HOME/.oac/runtimes/claude-sdk/dist/main.js" +export OAC_RUNTIME_CLAUDE_SDK_NODE="/absolute/path/to/node" +oac-daemon connect --profile default +``` + +Set `OAC_DEFAULT_HARNESS=claude_sdk` on the API service. Configure provider access in +the daemon's private native SDK environment. Runtime readiness checks versions and +startup before daemon registration; it does not validate provider credentials. +SDK state stays under the daemon profile, independently of the replaceable bundle. +A ready SDK can start the daemon without a legacy CLI. Product `claude_code` remains +separate. Managed Node installation, runtime activation and registry publication +are not supplied by these commands. + +## Official client verification + +After preparing a dedicated test database, build the server and verify it with +the official client installed from the commit in `contracts/agents-api/upstream.json`: + +```bash +python -m pip install -r services/core/tests/requirements.txt +make build-core +OAC_TEST_SERVER_BIN="${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core/oac-core" \ + python services/core/tests/official_client.py +``` + +The test uses `OAC_TEST_DATABASE_URL`, temporary service keys and +fresh tenant IDs. The suite checks upstream and generated response schemas, retries, +ordering, tenant isolation, unsupported options and reads after a process restart, +without a model provider. It also runs the +[official Go client integration](../../packages/agents-client/README.md#go-client), using two +fresh tenants, and validates its created Sessions through the Python SDK. + +## Checks + +```bash +OAC_TEST_DATABASE_URL='postgres://.../oac_local_tests' \ + make check-core +``` + +Set `OAC_TEST_OFFICIAL_SDK_PYTHON` to the fixed SDK interpreter for the Store client +fixtures, and run the separate official-client command above as well. +`TestEnvironmentRetrievalOfficialClient` verifies public creation, scoped safe +Environment reads and retrieval after reopening without execution configuration. +`TestEnvironmentInitialFailureOfficialClient` verifies failed Session reads and +matching SDK/raw live failure events after privately provisioned initial input +expires, without fabricating a Turn or changing the Environment status. It is a +persistence prerequisite test. `TestSelfHostedInitialCreationOfficialClient` +separately exercises ordinary/streamed public initial creation through the Worker, +retry identity, disconnect survival and explicitly controlled deadline failure. +The test database must be named `oac_*_tests` and contain no product +workspace tables. Tests apply only this service's migrations and use new tenant +IDs without truncating tables. Missing test configuration skips DB tests locally; +the `OpenAgentCore checks` CI workflow always supplies its own PostgreSQL service. Run the +full `make check` before review as well. Product OpenAPI generation excludes this +service; its supported HTTP contract is generated separately. + +Run `make openapi` after handler annotation changes. It reuses the original +Core-only swaggo v1.16.4 generator, then splits the result by namespace: `/v1` +into `openapi.yaml`, `/core/v1` into `core.openapi.yaml` and `/api/v1` into +`runtime.openapi.yaml`; the last two use base path `/`, and each document keeps +only the security schemes its operations use. All generated schemas remain free +of product routes. + +## Public text execution + +With the daemon gateway enabled, the service owns one worker per execution database +and processes up to four Turns concurrently. Other workers are rejected by a +PostgreSQL advisory lock. A disconnected host leaves unsent work queued; clients +may cancel it. Session/Turn/Items queries expose durable results. + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://127.0.0.1:8091/v1", api_key="") +session = client.beta.agents.sessions.create( + agent={"model": ""}, + environment={"type": "none"}, + input="Hello", + extra_headers={"Idempotency-Key": "first-message"}, +) +``` + +Configure model access in the engine host's native configuration. API tenant keys +and daemon credentials authenticate this service, not a model provider. Never put +provider secrets in Session metadata. This path does not enable Parsar Skill/SP +callbacks or bypass the pending product authorization work. + +Session creation admits initial text atomically; add `stream=True` for +created/live events. Open a GET event stream before submitting +later work, or use the official `sessions.stream` helper for one Turn. Function +handlers return results through the same public events endpoint. Recover missed +output with Session/Turn/Items queries; reconnecting SSE does not replay history. +See [creation streaming](../../contracts/agents-api/README.md#session-creation-streaming) +for retry behavior and unverified hosted timing. + +The [accepted workflows](../../contracts/agents-api/README.md#acceptance-evidence-and-remaining-scope) +include real MiniMax execution through built API/daemon/Codex and Claude SDK, +function success/error, cancellation and native continuation. Controlled fixtures +remain useful but do not replace real-provider acceptance for execution changes. + +## Historical Item storage + +Migration 15 records the retirement of private journal-to-Item backfilling. It +preserves public Items and source journals and refuses unindexed historical Turns. +This is historical schema evidence, not a supported upgrade procedure. Do not set +index markers manually, replay native execution, or run an older service to convert +a database for the current release. + +Historical installations are unsupported. Preserve their database and execution +history, and install the current release separately. Fresh database initialization +continues through the ordinary migration runner. Current recovery reads use +Session/Turn/Items; they do not provide SSE replay. + +## User-managed Runtime enrollment + +V1 uses our daemon as the user-side executor. Deploy daemon, selected harness, +local tools and protected `/workspace` together using the shared Runtime. For this caller-managed path, the user owns local or E2B allocation, renewal +and destruction; use the official E2B SDK through the +[E2B guide](deploy/e2b/README.md). Deployment-managed E2B, Docker and microsandbox +are separate hosted choices in the [nodes guide](../../docs/getting-started/nodes.md). + +Create a Session with `environment={"type":"self_hosted", +"workspace_directory":"/workspace"}` and empty/default capability directories. +Retain the returned Environment ID and unchanged `remote_url`. Initial text may +wait for connection; an idle Session creates no Turn. Codex, Claude SDK and MiniMax +reuse the same exact binding and `LocalEnvironment` preparation path. Service-origin +HTTP MCP is rejected on this placement; `none` MCP and separately qualified hosted +Environment Plugin MCP remain available within their own limits. + +An operator issues the Environment's connect-only executor credential with the +Core key, through Web or +`POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` +([executor credentials](../../contracts/agents-api/environment-executor-credentials.md)), +and gives the returned credential file to the executor host. With direct database +access, the operator CLI can instead issue a principal executor key: + +```bash +umask 077 +oac-core-environment-key --tenant "$TENANT_ID" \ + --organization "$ORGANIZATION_ID" --project "$PROJECT_ID" \ + --subject-kind service_account --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" \ + > "$HOME/.oac/executor-key.json" +``` + +The immutable principal must match a verified project mapping and the Session's +recorded creator. Add `--environment "$ENVIRONMENT_ID"` to restrict a key to one +live Environment. Keep the one-time `executor_token` output private. Only its digest +is stored; there is no secret read-back. `--rotate` and `--revoke` require the same +management ID and full principal; neither changes the key's restriction. Unknown +historical creators cannot enroll. API bearer keys and executor keys are separate. + +On the executor host, save that JSON as a private +`$OAC_RUNTIME_HOME/daemon/executor-key.json`, outside the tool workspace. Use the [native installer](../../docs/getting-started/self-hosted.md) to prepare the selected +Harnesses. Managed images preinstall them. A preconfigured Runtime can connect +with the values returned by Session creation: + +```bash +oac-daemon connect --remote "$REMOTE_URL" \ + --environment-id "$ENVIRONMENT_ID" \ + --credential-file "$OAC_RUNTIME_HOME/daemon/executor-key.json" +``` + +Use TLS outside loopback. Enrollment supplies the trusted Session identity; do +not inject an unrelated `OAC_RUNTIME_SESSION_ID`. The daemon persists its +immutable Environment binding beside the key and refuses to adopt another +Environment's existing native history. Rotation keeps the same key ID: update the +protected file, then restart the daemon to authenticate with the new token. +WebSocket authentication uses the `Authorization` header, never a URL token. +On a permanent rejection (enrollment 401 or 409, a permanent WebSocket rejection +or close) `connect --environment-id` prints one message naming the fix, makes no +further requests and exits 0 on SIGTERM or SIGINT; transient failures exit 1. + +The private `POST /api/v1/agent-daemon/enroll` endpoint accepts that executor bearer +and `{"environment_id":"..."}`. It returns `device_id`, `session_id`, +`environment_id` and `workspace_directory`, never another credential. Enrollment +atomically binds one dedicated device/key to the Session; retries retain it and +conflicting bindings reject. No managed `runtime_allocation` is created. Runtime +onboarding connects to the returned `remote_url` using that exact binding and key. +The endpoint is outside the pinned public Agents API, which remains unchanged. +It is not stock `exec-server`, `/cloud/environment/*` or Noise interoperability; +there is no service-side harness or remote tool forwarding compatibility path. + +The gateway and Worker recheck current credential authority. Rotation/revocation, +Session deletion and lost ownership deny further use; a replacement connection +cannot overwrite a successor's observations. Connection events report authenticated +connectivity, not native preparation readiness. Retained native history and workspace +must survive a Runtime restart for continuation; missing history fails closed. +Neither disconnect nor deletion promises immediate native process quiescence or +reclaims user-owned E2B/local compute. The user must stop and destroy it explicitly. + +Pending input retains its durable identity/deadline through HTTP disconnects. Later +idle input returns 202 after preparation/admission, not after model completion; +use client/proxy timeouts above five minutes and recover progress through events +and reads. Exact upstream failure/error timing remains unverified. + +The Runtime's shared Go workspace implementation owns directory reads, writes and +output export; Harness adapters use the same authorized workspace binding. +The [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md) +identifies fixed-SDK/raw HTTP, real-model, Files/Artifacts, cancellation, +restart/history and credential lifecycle evidence, with its recorded revision limits. + + +### HTTP MCP execution + +Public `agent.tools` declares an explicit MCP connection origin. Omitted/null +origin remains `service`. The [origin, credential and Harness matrix](../../contracts/agents-api/environments.md#public-mcp-connection-origin) +owns the supported combinations: Codex/Claude service HTTP on `none`, and +Codex/Claude/MiniMax Environment HTTP on managed or user-owned workspaces, subject +to declared native limits. Public declarations and installed Plugin MCP converge +on the same Runtime effective bindings; they retain distinct credential authority. + +Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}`; Claude SDK supports HTTP MCP with +`environment:{"type":"none"}`. Inline or saved Agent tools may declare: + +```json +{ + "type": "mcp", + "server_label": "tickets", + "transport": {"type": "http", "server_url": "https://mcp.example.com/mcp"}, + "connection_origin": "service", + "allowed_tools": ["lookup_ticket"], + "required": false +} +``` + +An omitted/null `allowed_tools` permits all tools from that server; `[]` permits +none. Native discovery may still connect to a declared server. The daemon must +advertise `mcp_http_tools`; selection waits for a capable device. The native +harness owns MCP discovery, calls and results. Public `mcp_call` Items use original +server/tool names; recover missed live events through Session, Turn and Items reads. + +With Codex, set `required:true` to require initialization before the first native Turn. It +defaults to false and additionally requires the pinned daemon's `mcp_http_required` +capability. Native root thread creation and cold resume wait for required servers; +initialization failure stops execution without replacing retained history. Public +work can already be accepted or queued during this wait. Exact hosted creation +timing/errors and continuing MCP health monitoring remain unverified. + +On `environment:none`, both Codex and Claude SDK support tenant-owned `vault_ids` +for static-bearer HTTPS MCP. `self_hosted` is explicitly unsupported for +service-origin MCP in V1, including anonymous requests. +An explicit +`credential_id` selects an attached credential for the exact HTTPS URL; omission/null +selects a unique matching credential, or stays anonymous if none matches. Ambiguity +fails before Session creation with 409 `conflict_error`. Selection is frozen +privately; Session reads and events show an implicitly selected credential ID in the +public tool, while the stored request keeps the caller's field. See +[credential setup and limits](credentials.md). +Authenticated execution additionally requires `mcp_http_bearer_auth`; missing keys +or failed authorization/decryption never fall back to anonymous execution. + +The colocated V1 `self_hosted` profile does not admit service-origin MCP. The old +separate executor/service-side MCP combination and its remote capability gates are +retired. Do not forward Vault credentials to user-owned Runtime compute. + +Claude SDK requires a packaged runtime that reports `mcp_http_tools`; the SDK +version alone does not qualify an older bundle. Its current profile +accepts either `required` value, requires connected servers and static inventories. Server labels +accept ASCII letters, digits, underscore and hyphen, except reserved `functions`; +selected tool names additionally accept dots. Declared HTTP MCP tools compose with +host functions; undeclared servers, built-ins and subagents remain disabled. +Authenticated requests also require `mcp_http_bearer_auth`. The existing Vault +selection rules apply, including implicit exact-URL matches and immutable private +bindings. Per-server/per-launch environment references keep bearer values out of +native argv and stored state. A Vault with no matching credential may stay anonymous. +Anonymous requests suppress native OAuth/credential injection with a blank +Authorization header, without deleting native state. Servers rejecting that header, normalized +name collisions, changing inventories and original MCP metadata fidelity remain +gaps. Items retain the observed native JSON, which may differ from the original +MCP envelope. See the [Claude SDK profile](../../packages/claude-sdk-adapter/README.md#http-mcp). + +The current subset rejects native OAuth login, inline authorization, nonempty headers or +request metadata, URL userinfo/query/fragment, the `environment` origin, stdio +and engines other than Codex/Claude SDK. The Codex adapter also +rejects reserved native labels and stored native MCP credentials. It verifies +exact effective MCP configuration before starting/resuming a native thread, +excludes undeclared servers and disables native apps/plugins. This runs on trusted +service compute; it does not provide filesystem isolation or guard against +concurrent operator configuration mutation. These limits are implementation gaps, +not changes to the pinned official protocol. + +A dedicated local Runtime uses one Environment-scoped device credential and an +immutable binding to that Environment's Session. It is excluded from general +device selection; another Session cannot claim it, including within the same +tenant. Deleting its Session invalidates credential lookup and heartbeat renewal. +Provision a new scoped device atomically rather than widening an existing shared +device credential. Revocation does not authorize silent placement replacement. + +The private local Environment reference contains its identity and, for policy-aware +execution, its immutable network policy. Trusted Runtime deployment configuration +freezes the Environment, Session and workspace root; +requests cannot supply a replacement root. The V1 path uses only the exact local +Environment reference. Use the same preparation/start lifecycle for native execution and the +existing bounded workspace controls for directory access. Local idle directory +reads use the common Go filesystem implementation without a temporary Harness. These private capabilities alone do not authorize public requests or establish +Provider lifecycle. Self-hosted enrollment supplies the exact local binding. +Core rechecks the persisted Environment/device binding for preparation and active +reads; capability discovery cannot select or authorize a general device for this +placement. Local work uses the existing pending-input reservation and Worker +ownership without a remote connection resolver. The hosted profile supports `network.access: enabled`, `disabled` and exact-host +`restricted`; each image must qualify the supported policies before public deployment. Omitted +network settings mean enabled upstream and must not be silently treated as disabled. + +User-managed Runtime enrollment authenticates the existing principal executor key +against the exact live Session/Environment and its recorded creator. Keys retain +stable management IDs, immutable principals, optional exact-Environment restrictions, +rotation/revocation and digest-only storage. They grant connection authority, never +Session API access. No raw-token import or secret read-back is added. + +Enrollment atomically creates or recovers one dedicated device and immutable Session +binding under the Session lock. The frozen workspace and capability directory selection come from the Session +configuration and must match the local binding. Runtime snapshots declared local +Skill or Plugin directories before creating the native executor. No `runtime_allocation` is +created for user-owned compute. A retry cannot replace a device, change its bound +key or adopt another native history. Gateway authentication and dispatch recheck +current key authority; rotation/revocation and deletion deny further use. + +The daemon, selected harness, local tools and workspace run together. The Dispatcher +passes the existing typed `LocalEnvironment` after exact tenant/Session/Environment/ +device checks. Native preparation, Files and Artifacts reuse the same protected +local workspace and existing lifecycle owners. There is no registry/Noise relay, +transient harness credential, service-side harness or remote tool forwarding path. +Keep model credentials, daemon authorization and native histories private; connection +success alone establishes neither native readiness nor filesystem isolation. + +## Hosted sandbox nodes + +The release includes `oac-node` for local and remote hosts. Add nodes with +Web's one-command flow in the [nodes guide](../../docs/getting-started/nodes.md), which +also covers provider selection, manual registration and reset. + +Implementation rules for hosted sandbox nodes and optional suspension are in +[Agents API implementation constraints](IMPLEMENTATION.md#hosted-sandbox-nodes-and-optional-suspension). diff --git a/services/core/RELEASE.md b/services/core/RELEASE.md new file mode 100644 index 000000000..e1faa8a7c --- /dev/null +++ b/services/core/RELEASE.md @@ -0,0 +1,47 @@ +# Standalone Core archive + +This Linux amd64 archive holds Core alone: the API server `oac-core`, its migrator `oac-core-migrate`, and the operator commands `oac-core-device`, `oac-core-environment-key` and `oac-node`. It has no Web console, installer or `oac` command, and it needs your own PostgreSQL. To install Core with Web and nodes, use the [installation guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/getting-started/install.md). + +## Verify and extract + +Verify the archive checksum supplied with the package, then extract it into a new directory under `~/.oac/`. Keep configuration outside the extracted package so that replacing the binaries does not replace credentials or state. + +```sh +sha256sum -c @ARCHIVE_NAME@.tar.gz.sha256 +mkdir -p "$HOME/.oac/releases" +tar -xzf @ARCHIVE_NAME@.tar.gz -C "$HOME/.oac/releases" +cd "$HOME/.oac/releases/@ARCHIVE_NAME@" +sha256sum -c SHA256SUMS +core_bin_dir="$PWD/bin" +``` + +`manifest.json` records the source commit and tree, the platform, the Go version, the pinned upstream protocol and the binary hashes. Checksums detect changed bytes; obtain the archive and its checksum from a trusted source. + +## Configure and start + +Create a dedicated PostgreSQL database and account. Generate a random Core key, keep it in private storage, and write its lowercase hex SHA-256 digest as a JSON array to `core-key-digests.json`: + +```sh +umask 077 +core_config_dir="$HOME/.oac/oac-core-deployment" +mkdir -p "$core_config_dir" +export OAC_DATABASE_URL='postgres://:@/' +export OAC_CORE_KEY_DIGESTS_FILE="$core_config_dir/core-key-digests.json" +export OAC_ADDR=127.0.0.1:8091 +export OAC_PUBLIC_URL=http://127.0.0.1:8091 +``` + +`OAC_DATABASE_URL` and `OAC_CORE_KEY_DIGESTS_FILE` are required. `OAC_PUBLIC_URL` is the origin that applications and machines use to reach Core: an HTTPS origin, or plain HTTP on loopback only. The [configuration reference](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. Run the migrations, then start Core in the foreground or under your own supervisor: + +```sh +"$core_bin_dir/oac-core-migrate" +"$core_bin_dir/oac-core" +``` + +`GET /healthz` reports liveness. One Core process serves each database; replicas add no availability. Keep the database when you replace the binaries. Put a TLS reverse proxy in front for remote clients. + +## Next steps + +- Use the Core key with the [administrator API](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/contracts/agents-api/admin-api.md) to create a Project and issue its API key, then follow the [quickstart](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/getting-started/quickstart.md). +- To run Sessions on your own machines, follow the [self-hosted guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/getting-started/self-hosted.md). The one-command installation needs the native installer catalog of the same commit: set `OAC_NATIVE_INSTALLER_DIR` to the `native-installers` directory of that commit's Core distribution, as the [configuration reference](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/configuration.md#appendix-core-environment-without-the-installer) describes. The [executor credential contract](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/contracts/agents-api/environment-executor-credentials.md) covers issuing credentials with the Core key and `oac-core-environment-key`. +- To add sandbox nodes with `oac-node`, see the [node guide](https://github.com/MiniMax-AI/OpenAgentCore/blob/@SOURCE_REVISION@/docs/getting-started/nodes.md). diff --git a/services/core/cmd/device/main.go b/services/core/cmd/device/main.go new file mode 100644 index 000000000..e66bd74ce --- /dev/null +++ b/services/core/cmd/device/main.go @@ -0,0 +1,76 @@ +// Command device provisions or revokes an execution device using operator DB access. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("execution device provisioning failed", "error", err) + os.Exit(1) + } +} + +func run() error { + tenant := flag.String("tenant", "", "execution tenant UUID") + name := flag.String("name", "", "device label") + serverURL := flag.String("url", "", "Agents API HTTP base URL") + revoke := flag.String("revoke", "", "revoke this device UUID instead of provisioning") + flag.Parse() + dsn, err := databaseurl.FromEnvironment() + if err != nil { + return err + } + if dsn == "" || *tenant == "" || flag.NArg() != 0 { + return errors.New("OAC_DATABASE_URL and --tenant are required") + } + if *revoke == "" { + u, err := url.Parse(*serverURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return errors.New("--url must be an absolute http(s) base URL without a path or credentials") + } + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return errors.New("invalid execution database configuration") + } + defer pool.Close() + s := store.New(pool) + if *revoke != "" { + return s.RevokeDevice(ctx, *tenant, *revoke) + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + credential := base64.RawURLEncoding.EncodeToString(secret) + registered, err := s.CreateDevice(ctx, *tenant, *name, device.HashCredential(credential)) + if err != nil { + return err + } + // Emit the existing daemon profile shape. Operators redirect this secret to a + // mode-0600 auth.json under ~/.oac; it is never included in diagnostic logs. + return json.NewEncoder(os.Stdout).Encode(map[string]string{ + "server_url": strings.TrimRight(*serverURL, "/") + "/api/v1", "runtime_id": registered.ID, + "runner_credential": credential, "device_name": registered.Name, + }) +} diff --git a/services/core/cmd/environment-key/main.go b/services/core/cmd/environment-key/main.go new file mode 100644 index 000000000..6554795ef --- /dev/null +++ b/services/core/cmd/environment-key/main.go @@ -0,0 +1,143 @@ +// Command environment-key manages executor principal credentials using operator DB authority. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "io" + "os" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if err := run(); err != nil && !errors.Is(err, flag.ErrHelp) { + log.Bg().Error("executor credential operation failed", "error", err) + os.Exit(1) + } +} + +type commandOptions struct { + principal identity.Principal + keyID string + environment string + rotate bool + revoke bool +} + +func parseOptions(args []string, helpOutput io.Writer) (commandOptions, error) { + var options commandOptions + flags := flag.NewFlagSet("oac-core-environment-key", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&options.principal.TenantID, "tenant", "", "execution tenant UUID with an existing project mapping") + flags.StringVar(&options.principal.OrganizationID, "organization", "", "execution organization ID") + flags.StringVar(&options.principal.ProjectID, "project", "", "execution project ID") + flags.StringVar(&options.principal.SubjectKind, "subject-kind", "", "executor principal kind: user or service_account") + flags.StringVar(&options.principal.SubjectID, "subject-id", "", "executor principal subject ID") + flags.StringVar(&options.keyID, "key-id", "", "canonical nonzero executor key UUID") + flags.StringVar(&options.environment, "environment", "", "optional existing Environment UUID restriction for issuance only") + flags.BoolVar(&options.rotate, "rotate", false, "explicitly replace the existing credential, including a revoked credential") + flags.BoolVar(&options.revoke, "revoke", false, "revoke the existing credential without issuing a secret") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(helpOutput) + flags.PrintDefaults() + return commandOptions{}, flag.ErrHelp + } + return commandOptions{}, errors.New("invalid executor credential arguments; use --help") + } + if flags.NArg() != 0 || (options.rotate && options.revoke) { + return commandOptions{}, errors.New("positional arguments are not accepted; --rotate and --revoke are mutually exclusive") + } + if options.principal.TenantID == "" || options.principal.OrganizationID == "" || + options.principal.ProjectID == "" || options.principal.SubjectKind == "" || + options.principal.SubjectID == "" || options.keyID == "" { + return commandOptions{}, errors.New("--tenant, --organization, --project, --subject-kind, --subject-id and --key-id are required") + } + if err := options.principal.Validate(); err != nil { + return commandOptions{}, err + } + if !canonicalUUID(options.keyID) { + return commandOptions{}, errors.New("--key-id must be a canonical nonzero UUID") + } + environmentSet := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "environment" { + environmentSet = true + } + }) + if environmentSet && (options.rotate || options.revoke) { + return commandOptions{}, errors.New("--environment is only accepted for issuance; rotation and revocation retain the stored restriction") + } + if environmentSet && !canonicalUUID(options.environment) { + return commandOptions{}, errors.New("--environment must be a canonical nonzero UUID") + } + return options, nil +} + +func canonicalUUID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} + +func run() error { + options, err := parseOptions(os.Args[1:], os.Stderr) + if err != nil { + return err + } + dsn, err := databaseurl.FromEnvironment() + if err != nil { + return err + } + if dsn == "" { + return errors.New("OAC_DATABASE_URL must point to a dedicated execution database") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return errors.New("invalid execution database configuration") + } + defer pool.Close() + s := store.New(pool) + if options.revoke { + return credentialOperationError(s.RevokeExecutorCredential(ctx, options.principal, options.keyID)) + } + var credential store.IssuedExecutorCredential + if options.rotate { + credential, err = s.RotateExecutorCredential(ctx, options.principal, options.keyID) + } else { + credential, err = s.IssueExecutorCredential(ctx, options.principal, options.keyID, options.environment) + } + if err != nil { + return credentialOperationError(err) + } + // Operators redirect stdout to a mode-0600 file under ~/.oac; no read-back operation exists. + if err := json.NewEncoder(os.Stdout).Encode(credential); err != nil { + return errors.New("could not write issued executor credential; rotate explicitly to replace it") + } + return nil +} + +func credentialOperationError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, store.ErrExecutorCredentialExists): + return store.ErrExecutorCredentialExists + case errors.Is(err, store.ErrNotFound): + return errors.New("executor principal project mapping or authorized credential target not found") + case errors.Is(err, store.ErrInvalidInput): + return errors.New("invalid executor credential identity or target") + default: + return errors.New("executor credential database operation failed") + } +} diff --git a/services/core/cmd/environment-key/main_test.go b/services/core/cmd/environment-key/main_test.go new file mode 100644 index 000000000..97c12ca4d --- /dev/null +++ b/services/core/cmd/environment-key/main_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +const ( + testTenant = "471e90e1-d9b3-418b-b339-928284514ae1" + testKey = "581d17e2-f063-42dc-b979-3fbd1c7a052c" + testEnvironment = "5c9751a6-df59-4612-912e-55e6a7898c5a" +) + +func principalArgs() []string { + return []string{ + "--tenant", testTenant, + "--organization", "test-org", + "--project", "test-project", + "--subject-kind", "service_account", + "--subject-id", "test-executor", + "--key-id", testKey, + } +} + +func TestParseOptionsPrincipalCredentialOperations(t *testing.T) { + for _, test := range []struct { + name string + args []string + environment string + rotate bool + revoke bool + }{ + {name: "principal issuance"}, + {name: "exact issuance", args: []string{"--environment", testEnvironment}, environment: testEnvironment}, + {name: "rotation", args: []string{"--rotate"}, rotate: true}, + {name: "revocation", args: []string{"--revoke"}, revoke: true}, + } { + t.Run(test.name, func(t *testing.T) { + options, err := parseOptions(append(principalArgs(), test.args...), io.Discard) + if err != nil { + t.Fatal(err) + } + if options.principal.TenantID != testTenant || options.principal.OrganizationID != "test-org" || + options.principal.ProjectID != "test-project" || options.principal.SubjectKind != "service_account" || + options.principal.SubjectID != "test-executor" { + t.Fatalf("unexpected principal: %+v", options.principal) + } + if options.keyID != testKey || options.environment != test.environment || options.rotate != test.rotate || options.revoke != test.revoke { + t.Fatalf("unexpected operation: %+v", options) + } + }) + } + options, err := parseOptions(append(principalArgs(), "--subject-kind", "user"), io.Discard) + if err != nil || options.principal.SubjectKind != "user" { + t.Fatalf("user principal rejected: %v", err) + } +} + +func TestParseOptionsRequiresEveryIdentityFlag(t *testing.T) { + for _, name := range []string{"--tenant", "--organization", "--project", "--subject-kind", "--subject-id", "--key-id"} { + t.Run(name, func(t *testing.T) { + args := principalArgs() + for i := 0; i < len(args); i += 2 { + if args[i] == name { + args = append(args[:i], args[i+2:]...) + break + } + } + if _, err := parseOptions(args, io.Discard); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("missing %s was not clearly rejected: %v", name, err) + } + }) + } + if _, err := parseOptions([]string{"--tenant", testTenant, "--environment", testEnvironment}, io.Discard); err == nil { + t.Fatal("legacy exact-Environment arguments were accepted") + } +} + +func TestParseOptionsRejectsInvalidIdentityAndRestrictionChanges(t *testing.T) { + for _, test := range []struct { + name string + args []string + }{ + {name: "conflicting operations", args: []string{"--rotate", "--revoke"}}, + {name: "rotation with restriction", args: []string{"--rotate", "--environment", testEnvironment}}, + {name: "revocation with restriction", args: []string{"--revoke", "--environment", testEnvironment}}, + {name: "rotation with empty restriction", args: []string{"--rotate", "--environment="}}, + {name: "revocation with empty restriction", args: []string{"--revoke", "--environment="}}, + {name: "empty issuance restriction", args: []string{"--environment="}}, + {name: "invalid environment", args: []string{"--environment", "invalid"}}, + {name: "noncanonical environment", args: []string{"--environment", strings.ToUpper(testEnvironment)}}, + {name: "zero environment", args: []string{"--environment", "00000000-0000-0000-0000-000000000000"}}, + {name: "invalid key", args: []string{"--key-id", "invalid"}}, + {name: "noncanonical key", args: []string{"--key-id", strings.ToUpper(testKey)}}, + {name: "zero key", args: []string{"--key-id", "00000000-0000-0000-0000-000000000000"}}, + {name: "noncanonical tenant", args: []string{"--tenant", strings.ToUpper(testTenant)}}, + {name: "empty organization", args: []string{"--organization", ""}}, + {name: "whitespace project", args: []string{"--project", " test-project"}}, + {name: "invalid subject kind", args: []string{"--subject-kind", "device"}}, + {name: "empty subject", args: []string{"--subject-id", ""}}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := parseOptions(append(principalArgs(), test.args...), io.Discard); err == nil { + t.Fatal("invalid arguments were accepted") + } + }) + } +} + +func TestParseOptionsRedactsValuesAndPreservesHelp(t *testing.T) { + const secret = "private-value-that-must-not-be-logged" + for _, args := range [][]string{ + {"--" + secret}, + {"--rotate=" + secret}, + {"--key-id", secret}, + {secret}, + } { + var output bytes.Buffer + _, err := parseOptions(append(principalArgs(), args...), &output) + if err == nil || strings.Contains(err.Error(), secret) || output.Len() != 0 { + t.Fatalf("invalid arguments were not safely rejected: %v; output: %q", err, output.String()) + } + } + var help bytes.Buffer + if _, err := parseOptions([]string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { + t.Fatalf("unexpected help result: %v", err) + } + if !strings.Contains(help.String(), "-key-id") || !strings.Contains(help.String(), "-subject-kind") { + t.Fatalf("help is missing principal credential arguments: %s", help.String()) + } +} + +func TestCredentialOperationErrorsDoNotExposeDatabaseValues(t *testing.T) { + const secret = "postgres://operator:private-password@database/execution" + for _, err := range []error{ + errors.New(secret), + fmt.Errorf("%w: %s", store.ErrInvalidInput, secret), + fmt.Errorf("%w: %s", store.ErrNotFound, secret), + fmt.Errorf("%w: %s", store.ErrExecutorCredentialExists, secret), + } { + redacted := credentialOperationError(err) + if redacted == nil || strings.Contains(redacted.Error(), secret) { + t.Fatalf("database failure was not redacted: %v", redacted) + } + } + if err := credentialOperationError(nil); err != nil { + t.Fatalf("successful revocation returned an error: %v", err) + } + if !errors.Is(credentialOperationError(store.ErrExecutorCredentialExists), store.ErrExecutorCredentialExists) { + t.Fatal("duplicate key guidance was lost") + } +} diff --git a/services/core/cmd/migrate/main.go b/services/core/cmd/migrate/main.go new file mode 100644 index 000000000..8b7079dc4 --- /dev/null +++ b/services/core/cmd/migrate/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "errors" + "os" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" + "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("oac-core migration failed", "error", err) + os.Exit(1) + } +} + +func run() error { + databaseURL, err := databaseurl.FromEnvironment() + if err != nil { + return err + } + if databaseURL == "" { + return errors.New("OAC_DATABASE_URL must point to a dedicated execution database") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + return migrations.Apply(ctx, databaseURL) +} diff --git a/services/core/cmd/sandbox-node/generations.go b/services/core/cmd/sandbox-node/generations.go new file mode 100644 index 000000000..27fdd53e8 --- /dev/null +++ b/services/core/cmd/sandbox-node/generations.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + providerconfig "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +func runGenerations(ctx context.Context, configFile, stateDir string) error { + root := filepath.Dir(configFile) + if stateDir != filepath.Join(root, "state", "node") { + return errors.New("generation state must belong to the installed node root") + } + stored, err := node.RefreshIdentity(ctx, stateDir) + if err != nil { + return err + } + base, err := providerconfig.Load(configFile) + if err != nil { + return err + } + if base.InstallationID != stored.Identity.InstallationID || base.Provider != stored.Identity.Provider || base.Generation != stored.Identity.DeploymentGeneration || base.Specification.Digest(base.Provider) != stored.Identity.SpecificationDigest { + return sandbox.ErrOwnership + } + paths, err := filepath.Glob(filepath.Join(stateDir, "generations", "*.json")) + if err != nil { + return err + } + pending, err := filepath.Glob(filepath.Join(stateDir, "generations", "*.preparing")) + if err != nil { + return err + } + paths = append(append([]string{configFile}, paths...), pending...) + values := map[uint64]node.GenerationProvider{} + recovery := []sandbox.GenerationReference{} + collection := []sandbox.GenerationReference{} + seen := map[uint64]providerconfig.Config{} + closeValues := func() { + for _, v := range values { + if v.Close != nil { + v.Close() + } + } + } + for _, path := range paths { + var config providerconfig.Config + if strings.HasSuffix(path, ".preparing") { + journal, readErr := readGenerationJournal(path) + err = readErr + if err == nil && journal.Configuration == nil { + err = sandbox.ErrOwnership + } + if err == nil { + config = *journal.Configuration + } + } else { + config, err = providerconfig.Load(path) + } + if err != nil { + closeValues() + return err + } + if config.InstallationID != base.InstallationID || config.Provider != base.Provider { + closeValues() + return sandbox.ErrOwnership + } + if path != configFile && strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), ".json"), ".preparing") != strconv.FormatUint(config.Generation, 10) { + closeValues() + return sandbox.ErrOwnership + } + state, err := generationLocalState(config, stateDir) + if err != nil { + closeValues() + return err + } + if state == "dropped" { + continue + } + if previous, ok := seen[config.Generation]; ok { + if !sameGenerationPlan(previous, config) { + closeValues() + return sandbox.ErrOwnership + } + continue + } + seen[config.Generation] = config + if state == "preparing" { + recovery = append(recovery, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) + continue + } + if state == "collecting" { + collection = append(collection, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) + continue + } + value, err := buildGeneration(config, stateDir) + if errors.Is(err, os.ErrNotExist) { + recovery = append(recovery, sandbox.GenerationReference{Generation: config.Generation, SpecificationDigest: config.Specification.Digest(config.Provider)}) + continue + } + if err != nil { + closeValues() + return err + } + values[config.Generation] = value + } + helper := filepath.Join(root, "generation-preparer.pyz") + runHelper := func(ctx context.Context, action string, generation uint64, digest string) error { + command := exec.CommandContext(ctx, "python3", helper, "--installation-id", base.InstallationID, "--generation-action", action, "--generation", strconv.FormatUint(generation, 10), "--specification-digest", digest) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Only the preparation/collection process group is canceled. Native sandbox + // helpers retain their independent allocation-lock completion semantics. + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.Stdout = os.Stderr + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return generationHelperError(ctx, err) + } + return nil + } + initial := make([]node.GenerationProvider, 0, len(values)) + for _, v := range values { + initial = append(initial, v) + } + manager, err := node.NewGenerationManager(ctx, node.GenerationManagerOptions{Initial: initial, Recover: recovery, Collect: collection, + Prepare: func(ctx context.Context, generation uint64, digest string) (node.GenerationProvider, error) { + if err := runHelper(ctx, "prepare", generation, digest); err != nil { + return node.GenerationProvider{}, err + } + config, err := providerconfig.Load(filepath.Join(stateDir, "generations", strconv.FormatUint(generation, 10)+".json")) + if err != nil { + return node.GenerationProvider{}, err + } + if config.InstallationID != base.InstallationID || config.Provider != base.Provider || config.Generation != generation || config.Specification.Digest(config.Provider) != digest { + return node.GenerationProvider{}, sandbox.ErrOwnership + } + value, err := buildGeneration(config, stateDir) + if err != nil { + return value, err + } + return value, nil + }, + Remove: func(ctx context.Context, value node.GenerationProvider) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + return runHelper(ctx, "collect", value.Generation, value.SpecificationDigest) + }, + }) + if err != nil { + closeValues() + return err + } + defer manager.Close() + return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: stateDir, Identity: stored.Identity, Credential: stored.Credential, Generations: manager}) +} + +func buildGeneration(config providerconfig.Config, stateDir string) (node.GenerationProvider, error) { + if config.Microsandbox != nil { + directory := filepath.Join(stateDir, "generations") + if err := os.MkdirAll(directory, 0700); err != nil { + return node.GenerationProvider{}, err + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + return node.GenerationProvider{}, sandbox.ErrOwnership + } + config.Microsandbox.HelperLeaseGeneration = config.Generation + config.Microsandbox.HelperLeasePath = filepath.Join(directory, strconv.FormatUint(config.Generation, 10)+".lease") + } + built, closeProvider, err := providerconfig.Build(config) + if err != nil { + return node.GenerationProvider{}, err + } + probe := built.Probe + if micro := config.Microsandbox; micro != nil { + probe = func(ctx context.Context) error { + if err := built.Probe(ctx); err != nil { + return err + } + command := exec.CommandContext(ctx, micro.RuntimePath, "image", "inspect", micro.Image, "--format", "json") + command.Env = append([]string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + os.Getenv("HOME")}, "MSB_BACKEND=local", "MSB_HOME="+micro.RuntimeHome, "MSB_PATH="+micro.RuntimePath, "MSB_LIBKRUNFW_PATH="+micro.FirmwarePath) + reader, err := command.StdoutPipe() + if err != nil { + return sandbox.ErrRuntimeImageUnavailable + } + if err := command.Start(); err != nil { + return sandbox.ErrRuntimeImageUnavailable + } + raw, readErr := io.ReadAll(io.LimitReader(reader, 64*1024+1)) + if len(raw) > 64*1024 { + _ = command.Process.Kill() + } + waitErr := command.Wait() + var image struct{ Digest, Architecture, OS string } + if readErr != nil || waitErr != nil || len(raw) > 64*1024 || json.Unmarshal(raw, &image) != nil || image.Digest != strings.SplitN(micro.Image, "@", 2)[1] || image.Architecture != "amd64" || image.OS != "linux" { + return sandbox.ErrRuntimeImageUnavailable + } + return nil + } + } + return node.GenerationProvider{Generation: config.Generation, SpecificationDigest: built.SpecificationDigest, Provider: built.Provider, Probe: probe, Close: closeProvider}, nil +} + +type generationJournal struct { + InstallationID string `json:"installation_id"` + Generation uint64 `json:"generation"` + SpecificationDigest string `json:"specification_digest"` + NativeComplete json.RawMessage `json:"native_complete,omitempty"` + ImportStarted json.RawMessage `json:"import_started,omitempty"` + Configuration *providerconfig.Config `json:"configuration,omitempty"` +} + +func readGenerationJournal(path string) (generationJournal, error) { + var journal generationJournal + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return journal, err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + var st syscall.Stat_t + err = syscall.Fstat(fd, &st) + resolved, pathErr := filepath.EvalSymlinks(path) + if err != nil || pathErr != nil || resolved != path || st.Mode&syscall.S_IFMT != syscall.S_IFREG || st.Mode&0777 != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 || st.Size > 16384 { + return journal, sandbox.ErrOwnership + } + decoder := json.NewDecoder(io.LimitReader(file, 16385)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&journal) + var trailing any + end := decoder.Decode(&trailing) + opened, statErr := file.Stat() + named, namedErr := os.Lstat(path) + if err != nil || end != io.EOF || statErr != nil || namedErr != nil || !os.SameFile(opened, named) { + return journal, sandbox.ErrOwnership + } + return journal, nil +} + +// The preparation configuration is an immutable plan, never a usable provider. +// Only Docker's two specification-proven local IDs can differ at publication. +func sameGenerationPlan(final, plan providerconfig.Config) bool { + if plan.Docker != nil && final.Docker != nil { + runtime := plan.Specification.Runtime + if final.Docker.Image != runtime.ImageID && final.Docker.Image != runtime.ImageManifestDigest { + return false + } + copyDocker := *plan.Docker + copyDocker.Image = final.Docker.Image + plan.Docker = ©Docker + } + return reflect.DeepEqual(final, plan) +} + +// Pending-only entries stay recovery/collection-only. No journal is readiness +// or authority to collect without a fresh current-connection Core grant. +func generationLocalState(config providerconfig.Config, stateDir string) (string, error) { + directory := filepath.Join(stateDir, "generations") + info, err := os.Lstat(directory) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + owner, ok := info.Sys().(*syscall.Stat_t) + resolved, err := filepath.EvalSymlinks(directory) + if err != nil || resolved != directory || !info.IsDir() || info.Mode().Perm() != 0700 || !ok || owner.Uid != uint32(os.Getuid()) { + return "", sandbox.ErrOwnership + } + state := "" + for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { + path := filepath.Join(directory, strconv.FormatUint(config.Generation, 10)+suffix) + journal, err := readGenerationJournal(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil || journal.InstallationID != config.InstallationID || journal.Generation != config.Generation || journal.SpecificationDigest != config.Specification.Digest(config.Provider) { + return "", sandbox.ErrOwnership + } + if suffix == ".preparing" { + if len(journal.NativeComplete) != 0 || (string(journal.ImportStarted) != "true" && string(journal.ImportStarted) != "false") || journal.Configuration == nil || !sameGenerationPlan(config, *journal.Configuration) { + return "", sandbox.ErrOwnership + } + } else if len(journal.ImportStarted) != 0 || journal.Configuration != nil || suffix == ".collecting" && string(journal.NativeComplete) != "true" && string(journal.NativeComplete) != "false" || suffix == ".dropped" && len(journal.NativeComplete) != 0 { + return "", sandbox.ErrOwnership + } + state = strings.TrimPrefix(suffix, ".") + } + return state, nil +} + +// Exit 65 is reserved by the private preparer for artifact transfer/provenance. +// Arbitrary provider output never selects a wire diagnostic. +func generationHelperError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 65 { + return sandbox.ErrRuntimeDownloadFailed + } + return errors.New("local generation operation did not complete") +} diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go new file mode 100644 index 000000000..daeb9b83c --- /dev/null +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + providerconfig "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +func TestGenerationJournalRestartIdentity(t *testing.T) { + config := providerconfig.Config{InstallationID: "installation", Generation: 9, Provider: "docker"} + stateDir := t.TempDir() + directory := filepath.Join(stateDir, "generations") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + journal := map[string]any{"installation_id": config.InstallationID, "generation": config.Generation, "specification_digest": config.Specification.Digest(config.Provider)} + for _, suffix := range []string{".collecting", ".dropped"} { + t.Run(suffix, func(t *testing.T) { + path := filepath.Join(directory, "9"+suffix) + if suffix == ".collecting" { + journal["native_complete"] = true + } else { + delete(journal, "native_complete") + } + write := func(value map[string]any) { + raw, _ := json.Marshal(value) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + write(journal) + state, err := generationLocalState(config, stateDir) + if err != nil || state != suffix[1:] { + t.Fatal(state, err) + } + for _, field := range []string{"installation_id", "generation", "specification_digest"} { + previous := journal[field] + journal[field] = "foreign" + write(journal) + if _, err = generationLocalState(config, stateDir); err == nil { + t.Fatal("accepted mismatched", field) + } + journal[field] = previous + } + journal["native_complete"] = nil + write(journal) + if _, err = generationLocalState(config, stateDir); err == nil { + t.Fatal("accepted null journal phase") + } + if suffix == ".collecting" { + journal["native_complete"] = true + } else { + delete(journal, "native_complete") + } + write(journal) + if err = os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err = generationLocalState(config, stateDir); err == nil { + t.Fatal("accepted non-private marker") + } + if err = os.Remove(path); err != nil { + t.Fatal(err) + } + foreign := filepath.Join(directory, "foreign") + raw, _ := json.Marshal(journal) + if err = os.WriteFile(foreign, raw, 0600); err != nil { + t.Fatal(err) + } + for _, link := range []func(string, string) error{os.Symlink, os.Link} { + if err = link(foreign, path); err != nil { + t.Fatal(err) + } + if _, err = generationLocalState(config, stateDir); err == nil { + t.Fatal("accepted linked journal") + } + if err = os.Remove(path); err != nil { + t.Fatal(err) + } + } + if err = syscall.Mkfifo(path, 0600); err != nil { + t.Fatal(err) + } + if _, err = generationLocalState(config, stateDir); err == nil { + t.Fatal("accepted FIFO journal") + } + if err = os.Remove(path); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGenerationHelperUsesOnlyTypedExit(t *testing.T) { + for _, code := range []string{"1", "65", "78"} { + err := exec.Command("sh", "-c", "exit "+code).Run() + got := generationHelperError(t.Context(), err) + if errors.Is(got, sandbox.ErrRuntimeDownloadFailed) != (code == "65") { + t.Fatal(code, got) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if !errors.Is(generationHelperError(ctx, err), context.Canceled) { + t.Fatal("cancellation was lost") + } + } + if errors.Is(generationHelperError(t.Context(), errors.New("runtime_download_failed")), sandbox.ErrRuntimeDownloadFailed) { + t.Fatal("parsed arbitrary provider text") + } +} + +func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { + config := providerconfig.Config{InstallationID: "installation", Generation: 2, Provider: "docker"} + stateDir := t.TempDir() + directory := filepath.Join(stateDir, "generations") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "2.preparing") + for _, started := range []bool{false, true} { + raw, _ := json.Marshal(map[string]any{"installation_id": config.InstallationID, "generation": config.Generation, "specification_digest": config.Specification.Digest(config.Provider), "import_started": started, "configuration": config}) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + journal, err := readGenerationJournal(path) + if err != nil || journal.Configuration == nil { + t.Fatal("pending-only discovery", err) + } + state, err := generationLocalState(*journal.Configuration, stateDir) + if err != nil || state != "preparing" { + t.Fatal(state, err) + } + if _, err := os.Stat(filepath.Join(directory, "2.json")); !os.IsNotExist(err) { + t.Fatal("unresolved plan published") + } + } +} diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go new file mode 100644 index 000000000..29161247a --- /dev/null +++ b/services/core/cmd/sandbox-node/main.go @@ -0,0 +1,152 @@ +// sandbox-node hosts the selected local Provider and dials its owning Core. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + providerconfig "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(exitCode(err)) + } +} + +// exitRejected tells the service manager not to restart the node: Core rejected +// its credential because the node was removed or retired, so no retry can succeed. +// The node units set RestartPreventExitStatus to this value (EX_CONFIG). +const exitRejected = 78 + +func exitCode(err error) int { + if errors.Is(err, node.ErrAuthentication) { + return exitRejected + } + return 1 +} + +func run(ctx context.Context, args []string) error { + if renamed := log.RenamedEnvironment(); len(renamed) > 0 { + return errors.New("OpenAgentCore renamed these settings; set the new names and remove the old ones: " + strings.Join(renamed, ", ")) + } + + if len(args) == 1 && args[0] == "protocol-version" { + fmt.Println(node.ProtocolVersion) + return nil + } + if len(args) == 0 || (args[0] != "register" && args[0] != "run") { + return errors.New("usage: oac-node register|run --config PATH --state-dir PATH") + } + flags := flag.NewFlagSet("sandbox-node "+args[0], flag.ContinueOnError) + configFile := flags.String("config", "", "absolute provider configuration file") + stateDir := flags.String("state-dir", "", "absolute private node state directory") + coreURL := flags.String("core-url", "", "Core HTTPS origin (register only)") + name := flags.String("name", "sandbox-node", "display name (register only)") + tokenFile := flags.String("enrollment-token-file", "", "private single-use enrollment token file (register only)") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("unexpected arguments") + } + if !filepath.IsAbs(*configFile) || !filepath.IsAbs(*stateDir) { + return errors.New("config and state-dir must be absolute paths") + } + if args[0] == "run" { + helper := filepath.Join(filepath.Dir(*configFile), "generation-preparer.pyz") + if info, err := os.Lstat(helper); err == nil { + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return errors.New("generation preparer must be a private regular file") + } + return runGenerations(ctx, *configFile, *stateDir) + } else if !os.IsNotExist(err) { + return err + } + } + config, err := providerconfig.Load(*configFile) + if err != nil { + return err + } + built, closeProvider, err := providerconfig.Build(config) + if err != nil { + return err + } + defer closeProvider() + log.Ctx(ctx).Info("sandbox node configuration loaded", "config_path", *configFile) + if built.Provider == nil { + return errors.New("node configuration requires one local provider backend") + } + expected := node.Identity{SpecificationDigest: built.SpecificationDigest, DeploymentGeneration: config.Generation, InstallationID: built.InstallationID, Provider: config.Provider, BackendFingerprint: built.BackendFingerprint} + probe := func(ctx context.Context) (node.Health, error) { + err := built.Probe(ctx) + return node.Health{ProviderReady: err == nil}, err + } + if args[0] == "register" { + if !filepath.IsAbs(*tokenFile) { + return errors.New("enrollment-token-file must be absolute") + } + + fd, err := syscall.Open(*tokenFile, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) + if err != nil { + return errors.New("cannot open enrollment token") + } + file := os.NewFile(uintptr(fd), "enrollment token") + st, err := file.Stat() + if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { + file.Close() + return errors.New("enrollment token must be a private regular file (0600)") + } + raw, err := io.ReadAll(io.LimitReader(file, 4097)) + file.Close() + if err != nil { + return errors.New("cannot read enrollment token") + } + token := strings.TrimSpace(string(raw)) + if token == "" || len(token) > 4096 { + return errors.New("invalid enrollment token") + } + if _, err = node.InitIdentity(*stateDir, *coreURL, expected); err != nil { + return err + } + probeCtx, stopProbe := context.WithTimeout(ctx, 5*time.Second) + _, err = probe(probeCtx) + stopProbe() + if err != nil { + return fmt.Errorf("local provider readiness check failed (%s): %w", sandbox.NodeDiagnostic(err), err) + } + stored, err := node.Enroll(ctx, *coreURL, *stateDir, token, node.EnrollmentRequest{Name: *name}) + if err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(node.EnrollmentResponse{MaxActive: stored.Identity.MaxActive, MaxRetained: stored.Identity.MaxRetained, SpecificationDigest: stored.Identity.SpecificationDigest, DeploymentGeneration: stored.Identity.DeploymentGeneration, NodeID: stored.Identity.NodeID, InstallationID: stored.Identity.InstallationID, Provider: stored.Identity.Provider}) + } + stored, err := node.RefreshIdentity(ctx, *stateDir) + if err != nil { + return err + } + expected.NodeID = stored.Identity.NodeID + if expected.SpecificationDigest != stored.Identity.SpecificationDigest || expected.DeploymentGeneration != stored.Identity.DeploymentGeneration || expected.InstallationID != stored.Identity.InstallationID || expected.Provider != stored.Identity.Provider || expected.BackendFingerprint != stored.Identity.BackendFingerprint { + return errors.New("provider configuration differs from retained node identity") + } + if *coreURL != "" && *coreURL != stored.CoreURL { + return errors.New("run uses the retained Core URL") + } + return node.Run(ctx, node.AgentConfig{CoreURL: stored.CoreURL, StateDirectory: *stateDir, Identity: stored.Identity, Credential: stored.Credential, Provider: built.Provider, Probe: probe}) +} diff --git a/services/core/cmd/sandbox-node/main_test.go b/services/core/cmd/sandbox-node/main_test.go new file mode 100644 index 000000000..df9259dda --- /dev/null +++ b/services/core/cmd/sandbox-node/main_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/google/uuid" +) + +// Only Core's rejection of the node credential stops the node service for good: +// exit 78, which the unit's RestartPreventExitStatus honors. Every other failure +// exits 1, so systemd restarts the node. +func TestExitCodeStopsTheNodeOnlyForARejectedCredential(t *testing.T) { + answering := func(status int) string { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })) + t.Cleanup(server.Close) + return server.URL + } + closed := httptest.NewServer(http.NotFoundHandler()) + closed.Close() + for _, test := range []struct { + name string + err error + want int + }{ + {"credential rejected", refreshIdentity(t, answering(http.StatusUnauthorized)), exitRejected}, + {"proxy forbids", refreshIdentity(t, answering(http.StatusForbidden)), 1}, + {"Core unavailable", refreshIdentity(t, answering(http.StatusServiceUnavailable)), 1}, + {"Core unreachable", refreshIdentity(t, closed.URL), 1}, + {"specification changed", errors.New("provider configuration differs from retained node identity"), 1}, + } { + if test.err == nil { + t.Fatal(test.name, "refresh succeeded") + } + if got := exitCode(test.err); got != test.want { + t.Errorf("%s: exit %d, want %d (%v)", test.name, got, test.want, test.err) + } + } +} + +// refreshIdentity runs the node's startup identity refresh against coreURL. +func refreshIdentity(t *testing.T, coreURL string) error { + t.Helper() + dir := filepath.Join(t.TempDir(), "state") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + identity := node.Identity{InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64)} + if _, err := node.InitIdentity(dir, coreURL, identity); err != nil { + t.Fatal(err) + } + _, err := node.RefreshIdentity(t.Context(), dir) + return err +} + +func TestNodeRejectsRetiredLoggingSettingsBeforeStartup(t *testing.T) { + t.Setenv("PARSAR_LOG_LEVEL", "private-log") + t.Setenv("PARSAR_LOG_FORMAT", "") + err := run(t.Context(), []string{"run"}) + if err == nil || !strings.Contains(err.Error(), "PARSAR_LOG_LEVEL → OAC_LOG_LEVEL") || !strings.Contains(err.Error(), "PARSAR_LOG_FORMAT → OAC_LOG_FORMAT") || strings.Contains(err.Error(), "private-log") { + t.Fatalf("retired logging settings were ignored or exposed: %v", err) + } +} + +func TestProtocolVersionRequiresNoProviderOrIdentity(t *testing.T) { + // This is a binary capability check, not a provider readiness probe. + if err := run(t.Context(), []string{"protocol-version"}); err != nil { + t.Fatal(err) + } + if err := run(t.Context(), []string{"protocol-version", "--config", "/missing"}); err == nil { + t.Fatal("extra protocol capability arguments accepted") + } +} diff --git a/services/core/cmd/server/auth_fixture_test.go b/services/core/cmd/server/auth_fixture_test.go new file mode 100644 index 000000000..ee6a509fe --- /dev/null +++ b/services/core/cmd/server/auth_fixture_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } +type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding + +func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { + if b, ok := f[digest]; ok { + return b, nil + } + return store.ProjectAPIKeyBinding{}, store.ErrNotFound +} +func newTestAuthenticator(keys []testAPIKey) (*api.Authenticator, error) { + resolver := fixtureKeyResolver{} + for _, k := range keys { + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} + if err := p.Validate(); err != nil { + return nil, err + } + digest, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(digest) != 32 { + return nil, errors.New("invalid fixture digest") + } + if _, exists := resolver[k.TokenSHA256]; exists { + return nil, errors.New("duplicate fixture digest") + } + resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + } + return api.NewDatabaseAuthenticator(resolver) +} diff --git a/services/core/cmd/server/core_metrics.go b/services/core/cmd/server/core_metrics.go new file mode 100644 index 000000000..d169d1ef9 --- /dev/null +++ b/services/core/cmd/server/core_metrics.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Release builders set this full source commit with -ldflags. +var buildRevision string +var processStartedAt = time.Now().UTC() + +type coreMetricsSource struct { + store *store.Store + pool *pgxpool.Pool + worker *execution.Worker + registry *gateway.Registry +} + +func metricPtr[T any](value T) *T { return &value } +func (s *coreMetricsSource) Live() coremetrics.Live { + stat := s.pool.Stat() + live := coremetrics.Live{Pool: coremetrics.Pool{InUse: metricPtr(int64(stat.AcquiredConns())), Idle: metricPtr(int64(stat.IdleConns())), Max: metricPtr(int64(stat.MaxConns()))}, Scheduler: coremetrics.Job{ID: "scheduler", Status: "stopped"}, ExecutionOwner: metricPtr(false), SlotsTotal: metricPtr(int64(0)), SlotsInUse: metricPtr(int64(0))} + if s.registry != nil { + live.ConnectedDaemons = metricPtr(int64(len(s.registry.Devices()))) + } + if s.worker != nil { + w := s.worker.MetricsSnapshot() + live.SlotsInUse, live.SlotsTotal, live.ExecutionOwner = w.SlotsInUse, w.SlotsTotal, w.ExecutionOwner + live.Scheduler = coremetrics.Job{ID: "scheduler", Status: w.Scheduler.Status, LastRunAt: w.Scheduler.LastRunAt, Processed: w.Scheduler.Processed, Failed: w.Scheduler.Failed} + } + return live +} +func (s *coreMetricsSource) Sample(ctx context.Context) coremetrics.Sample { + sample := coremetrics.Sample{Healthy: true, PoolInUse: metricPtr(int64(s.pool.Stat().AcquiredConns()))} + start := time.Now() + pingCtx, cancel := context.WithTimeout(ctx, time.Second) + err := s.pool.Ping(pingCtx) + cancel() + if err == nil { + sample.PingMS = metricPtr(float64(time.Since(start)) / float64(time.Millisecond)) + } else { + sample.Healthy = false + } + devices := []string{} + if s.registry != nil { + devices = s.registry.Devices() + } + counts, err := s.store.ReadCoreExecutionSnapshot(ctx, time.Now(), devices) + if err != nil { + sample.Healthy = false + } else { + sample.Queued, sample.InProgress = metricPtr(counts.QueuedTurns), metricPtr(counts.InProgressTurns) + sample.OldestQueuedSeconds = counts.OldestQueuedSeconds + if s.registry != nil { + sample.WaitingForDaemon = metricPtr(counts.WaitingForDaemon) + } + } + size, err := s.store.ReadCoreDatabaseSize(ctx) + if err != nil { + sample.Healthy = false + } else { + sample.DatabaseSize = &size + } + + return sample +} +func (s *coreMetricsSource) History(ctx context.Context, start, end time.Time, step time.Duration) (coremetrics.History, error) { + value, err := s.store.ReadCoreExecutionHistory(ctx, start, end, step) + if err != nil { + return coremetrics.History{}, err + } + result := coremetrics.History{Interrupted: value.Interrupted, QueueWaitMS: coremetrics.Latency{P50: value.QueueWaitMS.P50, P95: value.QueueWaitMS.P95}, Buckets: map[time.Time]*float64{}} + for _, bucket := range value.Buckets { + result.Buckets[bucket.Start.UTC()] = bucket.P95MS + } + return result, nil +} + +func reportCleanupResult(metrics *coremetrics.Service, job string, count int64, err error) { + if metrics == nil { + return + } + processed, failed := &count, int64(0) + if err != nil { + processed = nil + failed = 1 + } + metrics.ReportJob(job, time.Now(), processed, &failed, err) +} diff --git a/services/agents-api/cmd/server/credential_cipher.go b/services/core/cmd/server/credential_cipher.go similarity index 87% rename from services/agents-api/cmd/server/credential_cipher.go rename to services/core/cmd/server/credential_cipher.go index cf365ca89..60a6754c7 100644 --- a/services/agents-api/cmd/server/credential_cipher.go +++ b/services/core/cmd/server/credential_cipher.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" ) func credentialCipher() (*credentialcrypto.Cipher, error) { diff --git a/services/agents-api/cmd/server/credential_cipher_test.go b/services/core/cmd/server/credential_cipher_test.go similarity index 95% rename from services/agents-api/cmd/server/credential_cipher_test.go rename to services/core/cmd/server/credential_cipher_test.go index 765d02d80..be6bf1b1f 100644 --- a/services/agents-api/cmd/server/credential_cipher_test.go +++ b/services/core/cmd/server/credential_cipher_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" ) func TestCredentialCipherConfiguration(t *testing.T) { diff --git a/services/agents-api/cmd/server/daemon_bootstrap.go b/services/core/cmd/server/daemon_bootstrap.go similarity index 100% rename from services/agents-api/cmd/server/daemon_bootstrap.go rename to services/core/cmd/server/daemon_bootstrap.go diff --git a/services/agents-api/cmd/server/daemon_bootstrap_test.go b/services/core/cmd/server/daemon_bootstrap_test.go similarity index 92% rename from services/agents-api/cmd/server/daemon_bootstrap_test.go rename to services/core/cmd/server/daemon_bootstrap_test.go index f62f6155d..a96ff4e43 100644 --- a/services/agents-api/cmd/server/daemon_bootstrap_test.go +++ b/services/core/cmd/server/daemon_bootstrap_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" ) type bootstrapCredentialStore struct { diff --git a/services/agents-api/cmd/server/harnesses.go b/services/core/cmd/server/harnesses.go similarity index 90% rename from services/agents-api/cmd/server/harnesses.go rename to services/core/cmd/server/harnesses.go index e5e5eb59f..a2a0a7ecb 100644 --- a/services/agents-api/cmd/server/harnesses.go +++ b/services/core/cmd/server/harnesses.go @@ -6,7 +6,7 @@ import ( "slices" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" ) // Engine selection is independent of compute ownership. Operators may enable diff --git a/services/agents-api/cmd/server/harnesses_test.go b/services/core/cmd/server/harnesses_test.go similarity index 100% rename from services/agents-api/cmd/server/harnesses_test.go rename to services/core/cmd/server/harnesses_test.go diff --git a/services/agents-api/cmd/server/http_routes.go b/services/core/cmd/server/http_routes.go similarity index 95% rename from services/agents-api/cmd/server/http_routes.go rename to services/core/cmd/server/http_routes.go index a905f083d..536bdd549 100644 --- a/services/agents-api/cmd/server/http_routes.go +++ b/services/core/cmd/server/http_routes.go @@ -3,7 +3,7 @@ package main import ( "net/http" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" ) // daemonRoutes are the Runtime transport handlers served beside the API. Each diff --git a/services/agents-api/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go similarity index 98% rename from services/agents-api/cmd/server/http_routes_test.go rename to services/core/cmd/server/http_routes_test.go index 1f7090d2a..d231de3da 100644 --- a/services/agents-api/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -14,9 +14,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/cmd/server/installation.go b/services/core/cmd/server/installation.go new file mode 100644 index 000000000..68a0e9fbe --- /dev/null +++ b/services/core/cmd/server/installation.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "io" + "os" + "regexp" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// installationFacts reports what GET /core/v1/installation serves: Core's own +// environment and build, plus the installer's settings snapshot. Core never +// acts on the snapshot; it only reports it. +func installationFacts(publicURL string) (api.Installation, error) { + var facts api.Installation + if id := os.Getenv("OAC_INSTALLATION_ID"); id != "" { + facts.InstallationID = &id + } + if publicURL != "" { + base := publicURL + "/v1" + facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &publicURL, &base, store.LoopbackOrigin(publicURL) + } + if sourceCommit.MatchString(buildRevision) { + revision := buildRevision + facts.SourceCommit = &revision + } + path := os.Getenv("OAC_SETTINGS_FILE") + if path == "" { + return facts, nil + } + f, err := os.Open(path) + if err != nil { + return facts, errors.New("cannot read OAC_SETTINGS_FILE") + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 64<<10+1)) + if err != nil { + return facts, errors.New("cannot read OAC_SETTINGS_FILE") + } + facts.Configuration, err = api.ParseInstallationConfiguration(raw) + return facts, err +} diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go new file mode 100644 index 000000000..cef7e44e5 --- /dev/null +++ b/services/core/cmd/server/main.go @@ -0,0 +1,357 @@ +// Package main runs the standalone Agents API service. +// +// @title OpenAgentCore Agents API +// @version 1 +// @description Supported single-Agent execution resources from the pinned openai-python beta/agents contract. Bearer keys bind an execution principal to one project; optional OpenAI-Organization and OpenAI-Project headers must match that binding. +// @license.name Apache 2.0 +// @license.url https://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath /v1 +// @schemes http https +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @securityDefinitions.apikey DeploymentAdminAuth +// @in header +// @name Authorization +// @securityDefinitions.apikey NodeEnrollmentAuth +// @in header +// @name Authorization +// @securityDefinitions.apikey NodeAuth +// @in header +// @name Authorization +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + historystoreresolver "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory/storeresolver" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + observationstoreresolver "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/storeresolver" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("oac-core startup failed", "error", err) + os.Exit(1) + } +} + +func run() error { + log.Init(log.ConfigFromEnv()) + if err := validateProcessConfiguration(); err != nil { + return err + } + public, err := publicURL() + if err != nil { + return err + } + concurrency, err := executionConcurrency() + if err != nil { + return err + } + logConfigurationSources() + databaseURL, err := databaseurl.FromEnvironment() + if err != nil { + return err + } + if databaseURL == "" { + return errors.New("OAC_DATABASE_URL is required") + } + credentialKey, err := credentialCipher() + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return errors.New("invalid Agents API database configuration") + } + defer pool.Close() + ready, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := pool.Ping(ready); err != nil { + return errors.New("Agents API database connection failed") + } + engine := os.Getenv("OAC_DEFAULT_HARNESS") + if engine == "" { + engine = "codex" + } + kinds, err := enabledHarnesses(engine) + if err != nil { + return err + } + oauthClient, err := oauthRefreshClient() + if err != nil { + return err + } + executionStore := store.NewWithCredentialCipherAndOAuthRefresh(pool, credentialKey, oauthClient) + executionStore.SetPublicURL(public) + installation, err := installationFacts(public) + if err != nil { + return err + } + metricsSource := &coreMetricsSource{store: executionStore, pool: pool} + metrics := coremetrics.New(processStartedAt, buildRevision, metricsSource) + auth, err := api.NewDatabaseAuthenticator(executionStore) + if err != nil { + return err + } + auditRetention, err := writeAuditRetention() + if err != nil { + return err + } + auditCleanupCtx, cancelAuditCleanup := context.WithCancel(ctx) + auditCleanupDone := make(chan struct{}) + go func() { + defer close(auditCleanupDone) + runWriteAuditCleanup(auditCleanupCtx, executionStore, auditRetention, metrics) + }() + defer func() { cancelAuditCleanup(); <-auditCleanupDone }() + var workerDone chan error + var worker *execution.Worker + managedNodes, err := configureManagedNodes(executionStore, public, func(ctx context.Context) error { + if worker == nil { + return errors.New("sandbox execution owner is unavailable") + } + return worker.CheckOwnership(ctx) + }) + if err != nil { + return err + } + defer managedNodes.close() + var managed *execution.RuntimeProvider + if managedNodes != nil { + managed = managedNodes.runtime + } + observationSources := map[string]runtimeobs.Source{} + if managedNodes != nil && managedNodes.setup != nil { + observationSources[managed.InstallationID] = managedNodes.setup + } else if managed != nil { + if source, ok := managed.Provider.(runtimeobs.Source); ok { + observationSources[managed.InstallationID] = source + } + } + observationResolver, err := observationstoreresolver.NewResolver(executionStore) + if err != nil { + return err + } + history, err := runtimeHistory(ctx, executionStore, public != "") + if err != nil { + return err + } + observationService, err := runtimeobs.NewService(observationResolver, observationSources, history.Options...) + if err != nil { + if history.Exporter != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + closeRuntimeHistory(closeCtx, history.Exporter) + } + return err + } + defer func() { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = observationService.Close(closeCtx) + if history.Exporter != nil { + closeRuntimeHistory(closeCtx, history.Exporter) + } + }() + cleanupCtx, cancelCleanup := context.WithCancel(ctx) + cleanupDone := make(chan struct{}) + go func() { + defer close(cleanupDone) + runHistoryCleanup(cleanupCtx, history.Prune, metrics) + }() + defer func() { cancelCleanup(); <-cleanupDone }() + options := []api.Option{api.WithCoreMetrics(metrics), api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore), api.WithRuntimeObservations(observationService)} + if managedNodes != nil { + options = append(options, api.WithSandboxManager(executionStore, managedNodes.admin)) + options = append(options, api.WithSandboxE2BDiscovery(func(ctx context.Context, input api.SandboxE2BDiscoveryInput, template string) (api.SandboxE2BDiscoveryResult, error) { + binary := os.Getenv("OAC_E2B_PROVIDER_BIN") + if binary == "" { + binary = "/opt/oac/e2b/oac-e2b-provider" + } + out, err := e2b.Discover(ctx, &e2b.ProcessCaller{}, binary, input.APIKey, input.APIURL, input.Domain, template) + if err != nil { + return api.SandboxE2BDiscoveryResult{}, err + } + return api.SandboxE2BDiscoveryResult{Templates: out.Templates, Builds: out.Builds}, nil + })) + } + var keyAdmin *api.DeploymentAuthenticator + if managedNodes != nil { + keyAdmin = managedNodes.admin + } else { + keyAdmin, err = deploymentAdminAuthenticator() + if err != nil { + return err + } + } + if err := api.ValidateCredentialSeparation(ctx, keyAdmin, executionStore); err != nil { + return err + } + options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin), api.WithWriteAudit(executionStore, keyAdmin), api.WithAdminManagement(executionStore), + api.WithInstallation(installation, executionStore.AddressBindings)) + if history.Reader != nil { + historyResolver, resolverErr := historystoreresolver.NewResolver(executionStore) + if resolverErr != nil { + return resolverErr + } + historyService, serviceErr := runtimehistory.NewService(historyResolver, history.Reader) + if serviceErr != nil { + return serviceErr + } + options = append(options, api.WithRuntimeHistory(historyService)) + } + var daemonHandler http.Handler + var registry *gateway.Registry + if public != "" { + wsURL, err := runtimeWebSocketURL(public) + if err != nil { + return err + } + daemonHandler, registry, err = runtime.NewGateway(executionStore, wsURL) + if err != nil { + return err + } + defer runtime.CloseConnections(registry) + var catalog *nativeinstaller.Catalog + directory := os.Getenv("OAC_NATIVE_INSTALLER_DIR") + if directory == "" { + if _, err := os.Stat("/opt/oac/native-installers/catalog.json"); err == nil { + directory = "/opt/oac/native-installers" + } + } + if directory != "" { + catalog, err = nativeinstaller.Load(directory, buildRevision) + if err != nil { + return err + } + } + options = append(options, api.WithEnvironmentRemoteURL(wsURL), api.WithNativeInstaller(catalog, buildRevision)) + } + options = append(options, api.WithExecutorConnections(func(ctx context.Context, environment, digest string) (bool, error) { + return runtimeenrollment.RuntimeConnected(ctx, executionStore, registry, environment, digest) + })) + if registry != nil { + dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, + ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} + + worker, err = execution.StartWorker(ctx, dispatcher) + if err != nil { + return err + } + if managedNodes != nil && managedNodes.setup != nil { + options = append(options, api.WithSandboxDeploymentSetup(worker.InitializeSandboxDeployment), api.WithSandboxDeploymentChanges(worker.UpdateSandboxDeployment, worker.StartSandboxReset, worker.CancelSandboxReset)) + } + workerDone = make(chan error, 1) + go func() { workerDone <- worker.Run(ctx) }() + defer func() { + stop() + if workerDone != nil { + <-workerDone + } + }() + options = append(options, api.WithExecution(worker), api.WithSessionArchive(worker.ArchiveManagedSession), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) + options = append(options, api.WithHarnesses(kinds), api.WithModelProviderDefaults(executionStore.DeploymentModelProvider)) + if managed != nil { + options = append(options, api.WithHostedEnvironments()) + } + } + if history.SampleInterval == 0 { + metrics.StopJob("runtime_sampler") + } + if history.SampleInterval > 0 { + if worker == nil { + return errors.New("Runtime history periodic sampling requires the execution worker") + } + sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{ + Interval: history.SampleInterval, + Report: func(result runtimeobs.SweepResult) { + sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + sampleErr := worker.CheckOwnership(sampleCtx) + if sampleErr == nil { + _, sampleErr = executionStore.SampleNodeHostHistory(sampleCtx) + } + cancel() + if !result.Complete { + sampleErr = errors.New("incomplete Runtime sampling sweep") + } + metrics.ReportJob("runtime_sampler", result.CompletedAt, metricPtr(int64(result.Observed)), metricPtr(int64(result.Failed)), sampleErr) + fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} + if result.Complete { + log.Bg().Debug("Runtime history sampling sweep complete", fields...) + } else { + log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) + } + }, + }) + if err != nil { + return err + } + samplerCtx, cancelSampler := context.WithCancel(ctx) + samplerDone := make(chan error, 1) + go func() { defer metrics.StopJob("runtime_sampler"); samplerDone <- sampler.Run(samplerCtx) }() + defer func() { + cancelSampler() + <-samplerDone + }() + } + metricsSource.worker, metricsSource.registry = worker, registry + metricsCtx, cancelMetrics := context.WithCancel(ctx) + metricsDone := make(chan struct{}) + go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() + defer func() { cancelMetrics(); <-metricsDone }() + handler, err := api.NewHandler(executionStore, auth, engine, options...) + if err != nil { + return err + } + if daemonHandler != nil { + routes := daemonRoutes{gateway: daemonHandler, + enrollment: runtimeenrollment.EnrollmentHandler(executionStore), + connection: runtimeenrollment.ConnectionHandler(executionStore, registry)} + if managedNodes != nil { + routes.nodeConnect = managedNodes.hub + } + handler = serverHandler(handler, &routes) + } + addr := serverAddress() + server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + done := make(chan error, 1) + go func() { done <- server.ListenAndServe() }() + select { + case err := <-done: + return err + case err := <-workerDone: + workerDone = nil + stop() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdown) + } +} diff --git a/services/agents-api/cmd/server/managed_generation_operations.go b/services/core/cmd/server/managed_generation_operations.go similarity index 96% rename from services/agents-api/cmd/server/managed_generation_operations.go rename to services/core/cmd/server/managed_generation_operations.go index 3416cfa85..8c39850fa 100644 --- a/services/agents-api/cmd/server/managed_generation_operations.go +++ b/services/core/cmd/server/managed_generation_operations.go @@ -2,8 +2,8 @@ package main import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func (p *generationRouter) Initial(ctx context.Context, r sandbox.Reference) (sandbox.Compute, error) { diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/core/cmd/server/managed_generations.go similarity index 94% rename from services/agents-api/cmd/server/managed_generations.go rename to services/core/cmd/server/managed_generations.go index 23a265f06..e992c98b6 100644 --- a/services/agents-api/cmd/server/managed_generations.go +++ b/services/core/cmd/server/managed_generations.go @@ -3,15 +3,15 @@ package main import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "maps" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type generationStore interface { diff --git a/services/agents-api/cmd/server/managed_generations_test.go b/services/core/cmd/server/managed_generations_test.go similarity index 97% rename from services/agents-api/cmd/server/managed_generations_test.go rename to services/core/cmd/server/managed_generations_test.go index 4d9190d0b..ce948a408 100644 --- a/services/agents-api/cmd/server/managed_generations_test.go +++ b/services/core/cmd/server/managed_generations_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/cmd/server/managed_nodes.go b/services/core/cmd/server/managed_nodes.go similarity index 93% rename from services/agents-api/cmd/server/managed_nodes.go rename to services/core/cmd/server/managed_nodes.go index 8450b765b..2f3250290 100644 --- a/services/agents-api/cmd/server/managed_nodes.go +++ b/services/core/cmd/server/managed_nodes.go @@ -6,11 +6,11 @@ import ( "errors" "os" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go similarity index 92% rename from services/agents-api/cmd/server/managed_setup.go rename to services/core/cmd/server/managed_setup.go index 1167b664d..e3d3f4563 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -7,14 +7,14 @@ import ( "sync/atomic" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // managedSetup publishes one immutable selection to execution, bootstrap and diff --git a/services/agents-api/cmd/server/managed_setup_preflight_test.go b/services/core/cmd/server/managed_setup_preflight_test.go similarity index 96% rename from services/agents-api/cmd/server/managed_setup_preflight_test.go rename to services/core/cmd/server/managed_setup_preflight_test.go index a818e65a4..091aa8c98 100644 --- a/services/agents-api/cmd/server/managed_setup_preflight_test.go +++ b/services/core/cmd/server/managed_setup_preflight_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/cmd/server/managed_setup_test.go b/services/core/cmd/server/managed_setup_test.go similarity index 96% rename from services/agents-api/cmd/server/managed_setup_test.go rename to services/core/cmd/server/managed_setup_test.go index 0c42c3c7b..7e2d4d709 100644 --- a/services/agents-api/cmd/server/managed_setup_test.go +++ b/services/core/cmd/server/managed_setup_test.go @@ -10,10 +10,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/cmd/server/oauth_refresh.go b/services/core/cmd/server/oauth_refresh.go similarity index 81% rename from services/agents-api/cmd/server/oauth_refresh.go rename to services/core/cmd/server/oauth_refresh.go index c00fc68d2..8871bacd6 100644 --- a/services/agents-api/cmd/server/oauth_refresh.go +++ b/services/core/cmd/server/oauth_refresh.go @@ -4,7 +4,7 @@ import ( "os" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" ) func oauthRefreshClient() (*oauthrefresh.Client, error) { diff --git a/services/agents-api/cmd/server/oauth_refresh_test.go b/services/core/cmd/server/oauth_refresh_test.go similarity index 100% rename from services/agents-api/cmd/server/oauth_refresh_test.go rename to services/core/cmd/server/oauth_refresh_test.go diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/core/cmd/server/process_configuration.go similarity index 95% rename from services/agents-api/cmd/server/process_configuration.go rename to services/core/cmd/server/process_configuration.go index aa7380acd..59d4ef229 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/core/cmd/server/process_configuration.go @@ -7,9 +7,9 @@ import ( "strconv" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func executionConcurrency() (int, error) { diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/core/cmd/server/process_configuration_test.go similarity index 100% rename from services/agents-api/cmd/server/process_configuration_test.go rename to services/core/cmd/server/process_configuration_test.go diff --git a/services/core/cmd/server/runtime_history.go b/services/core/cmd/server/runtime_history.go new file mode 100644 index 000000000..308e84a55 --- /dev/null +++ b/services/core/cmd/server/runtime_history.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/url" + "os" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory/postgresreader" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/otlpexporter" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "golang.org/x/net/http/httpguts" +) + +const ( + defaultRuntimeHistoryQueueCapacity = 256 + defaultRuntimeHistoryTimeoutSeconds = 2 + maxRuntimeHistoryQueueCapacity = 4096 + maxRuntimeHistoryTimeoutSeconds = 30 + minRuntimeHistorySampleIntervalSeconds = 5 + maxRuntimeHistorySampleIntervalSeconds = 300 +) + +type runtimeHistoryConfig struct { + Transport string `json:"transport,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Insecure bool `json:"insecure,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + QueueCapacity int `json:"queue_capacity,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + SampleIntervalSeconds int `json:"sample_interval_seconds,omitempty"` +} + +type runtimeHistorySetup struct { + Options []runtimeobs.ServiceOption + Exporter runtimeHistoryExporter + Reader runtimehistory.Reader + SampleInterval time.Duration + Prune func(context.Context) (int64, error) +} + +type runtimeHistoryExporter interface { + runtimeobs.Exporter + Close(context.Context) error +} + +func runtimeHistory(ctx context.Context, coreStore *store.Store, executionEnabled bool) (runtimeHistorySetup, error) { + config, err := loadRuntimeHistoryConfig() + if err != nil { + return runtimeHistorySetup{}, err + } + interval := time.Duration(config.SampleIntervalSeconds) * time.Second + mode := runtimehistory.CollectionPeriodic + if !executionEnabled { + interval = 0 + mode = runtimehistory.CollectionOnRead + } + capabilities := runtimehistory.Capabilities{ + CollectionMode: mode, SampleInterval: interval, Retention: 7 * 24 * time.Hour, + MinimumStep: max(30*time.Second, interval), MaximumRange: 24 * time.Hour, + MaximumPoints: 1000, MaximumSeries: 64, MaximumTotalPoints: 10000, + Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, + } + timeout := time.Duration(config.TimeoutSeconds) * time.Second + backend, err := postgresreader.New(coreStore, postgresreader.Config{Capabilities: capabilities, QueryTimeout: timeout}) + if err != nil { + return runtimeHistorySetup{}, err + } + options := runtimeobs.ExportOptions{QueueCapacity: config.QueueCapacity, Timeout: timeout} + setup := runtimeHistorySetup{Options: []runtimeobs.ServiceOption{runtimeobs.WithExporter(backend, options)}, Reader: backend, SampleInterval: interval, Prune: backend.Prune} + if config.Endpoint != "" { + exporter, err := otlpexporter.New(ctx, otlpexporter.Config{Endpoint: config.Endpoint, Headers: config.Headers, Insecure: config.Insecure, RequestTimeout: timeout}) + if err != nil { + return runtimeHistorySetup{}, err + } + setup.Exporter = exporter + // Independent queues keep an external Collector outage from delaying local history. + setup.Options = append(setup.Options, runtimeobs.WithExporter(exporter, options)) + } + return setup, nil +} + +func loadRuntimeHistoryConfig() (runtimeHistoryConfig, error) { + var config runtimeHistoryConfig + if file := os.Getenv("OAC_HISTORY_SETTINGS_FILE"); file != "" { + raw, err := os.ReadFile(file) + if err != nil { + return config, errors.New("cannot read OAC_HISTORY_SETTINGS_FILE") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { + return config, errors.New("invalid Runtime history configuration") + } + } + if err := validateRuntimeHistoryConfig(config); err != nil { + return config, err + } + if config.QueueCapacity == 0 { + config.QueueCapacity = defaultRuntimeHistoryQueueCapacity + } + if config.TimeoutSeconds == 0 { + config.TimeoutSeconds = defaultRuntimeHistoryTimeoutSeconds + } + if config.SampleIntervalSeconds == 0 { + config.SampleIntervalSeconds = 30 + } + return config, nil +} + +func closeRuntimeHistory(ctx context.Context, exporter runtimeHistoryExporter) { + defer func() { _ = recover() }() + _ = exporter.Close(ctx) +} + +// Retention also runs without active Runtimes. Each bounded pass has its own deadline. +func runHistoryCleanup(ctx context.Context, prune func(context.Context) (int64, error), metrics *coremetrics.Service) { + if metrics != nil { + defer metrics.StopJob("history_cleanup") + } + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + pruneCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + count, err := prune(pruneCtx) + cancel() + reportCleanupResult(metrics, "history_cleanup", count, err) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func validateRuntimeHistoryConfig(config runtimeHistoryConfig) error { + if config.QueueCapacity < 0 || config.QueueCapacity > maxRuntimeHistoryQueueCapacity { + return errors.New("Runtime history queue_capacity is out of range") + } + if config.TimeoutSeconds < 0 || config.TimeoutSeconds > maxRuntimeHistoryTimeoutSeconds { + return errors.New("Runtime history timeout_seconds is out of range") + } + if config.SampleIntervalSeconds != 0 && (config.SampleIntervalSeconds < minRuntimeHistorySampleIntervalSeconds || config.SampleIntervalSeconds > maxRuntimeHistorySampleIntervalSeconds) { + return errors.New("Runtime history sample_interval_seconds is out of range") + } + if config.Endpoint == "" { + if config.Transport != "" || config.Insecure || len(config.Headers) != 0 { + return errors.New("Runtime history export options require an endpoint") + } + return nil + } + if config.Transport != "otlp_http" { + return errors.New("Runtime history transport must be otlp_http") + } + endpoint, err := url.Parse(config.Endpoint) + if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.RawPath != "" || endpoint.Path == "" || endpoint.String() != config.Endpoint { + return errors.New("Runtime history endpoint must be a canonical absolute OTLP metrics URL") + } + switch endpoint.Scheme { + case "https": + if config.Insecure { + return errors.New("Runtime history insecure transport requires an http endpoint") + } + case "http": + if !config.Insecure { + return errors.New("Runtime history http endpoint requires insecure=true") + } + default: + return errors.New("Runtime history endpoint scheme must be https or explicit insecure http") + } + for key, value := range config.Headers { + lower := strings.ToLower(key) + if !httpguts.ValidHeaderFieldName(key) || !httpguts.ValidHeaderFieldValue(value) || lower == "host" || lower == "content-length" || lower == "content-type" || lower == "content-encoding" { + return errors.New("Runtime history headers contain an invalid or reserved entry") + } + } + return nil +} diff --git a/services/core/cmd/server/runtime_history_test.go b/services/core/cmd/server/runtime_history_test.go new file mode 100644 index 000000000..4cf172842 --- /dev/null +++ b/services/core/cmd/server/runtime_history_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type panicHistoryExporter struct{} + +func (panicHistoryExporter) Export(context.Context, runtimeobs.ExportRecord) error { return nil } +func (panicHistoryExporter) Close(context.Context) error { panic("close") } + +func TestRuntimeHistoryUsesCoreDatabaseByDefault(t *testing.T) { + t.Setenv("OAC_HISTORY_SETTINGS_FILE", "") + for _, enabled := range []bool{true, false} { + setup, err := runtimeHistory(t.Context(), store.New(nil), enabled) + if err != nil { + t.Fatal(err) + } + if len(setup.Options) != 1 || setup.Exporter != nil || setup.Reader == nil || setup.Prune == nil { + t.Fatal("default history requires extra deployment") + } + capabilities := setup.Reader.Capabilities() + if capabilities.Durable() != enabled || capabilities.Retention != 7*24*time.Hour { + t.Fatalf("incorrect default capabilities: %+v", capabilities) + } + if enabled && setup.SampleInterval != 30*time.Second { + t.Fatal("default cadence missing") + } + if !enabled && setup.SampleInterval != 0 { + t.Fatal("sampler requires an execution owner") + } + } +} + +func TestRuntimeHistoryOptionalExportAndSamplingConfiguration(t *testing.T) { + file := filepath.Join(t.TempDir(), "history.json") + if err := os.WriteFile(file, []byte(`{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Authorization":"Bearer private"},"sample_interval_seconds":60}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) + setup, err := runtimeHistory(t.Context(), store.New(nil), true) + if err != nil { + t.Fatal(err) + } + defer closeRuntimeHistory(t.Context(), setup.Exporter) + if len(setup.Options) != 2 || setup.SampleInterval != time.Minute || setup.Reader.Capabilities().MinimumStep != time.Minute { + t.Fatal("export and local history are not independent") + } +} + +func TestRuntimeHistoryConfigFailsClosedWithoutLeakingSecrets(t *testing.T) { + tests := []struct { + name string + config string + }{ + {name: "unknown field", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","secret":"must-not-leak"}`}, + {name: "implicit insecure", config: `{"transport":"otlp_http","endpoint":"http://collector.example.test/v1/metrics"}`}, + {name: "userinfo", config: `{"transport":"otlp_http","endpoint":"https://user:must-not-leak@collector.example.test/v1/metrics"}`}, + {name: "header newline", config: "{\"transport\":\"otlp_http\",\"endpoint\":\"https://collector.example.test/v1/metrics\",\"headers\":{\"Authorization\":\"Bearer must-not-leak\\n\"}}"}, + {name: "reserved header", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Host":"must-not-leak"}}`}, + {name: "oversized queue", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","queue_capacity":4097}`}, + {name: "oversized timeout", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","timeout_seconds":31}`}, + {name: "too frequent sampling", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":4}`}, + {name: "oversized sampling interval", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":301}`}, + {name: "removed backend", config: `{"clickhouse":{"password":"must-not-leak"}}`}, + {name: "transport without endpoint", config: `{"transport":"otlp_http"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "runtime-history.json") + if err := os.WriteFile(file, []byte(test.config), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) + _, err := loadRuntimeHistoryConfig() + if err == nil { + t.Fatal("unsafe history configuration accepted") + } + if strings.Contains(err.Error(), "must-not-leak") { + t.Fatalf("history error leaked config content: %v", err) + } + }) + } +} + +func TestRuntimeHistoryAllowsExplicitLocalHTTPCollector(t *testing.T) { + config := runtimeHistoryConfig{ + Transport: "otlp_http", Endpoint: "http://127.0.0.1:4318/v1/metrics", Insecure: true, + Headers: map[string]string{"X-Scope-OrgID": "operator-history"}, + } + if err := validateRuntimeHistoryConfig(config); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeHistoryClosePanicIsIsolated(t *testing.T) { + closeRuntimeHistory(t.Context(), panicHistoryExporter{}) +} diff --git a/services/core/cmd/server/write_audit.go b/services/core/cmd/server/write_audit.go new file mode 100644 index 000000000..481533521 --- /dev/null +++ b/services/core/cmd/server/write_audit.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "errors" + "os" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" +) + +type writeAuditPruner interface { + DeleteExpiredWriteOperations(context.Context, time.Time, int) (int64, error) +} + +func writeAuditRetention() (time.Duration, error) { + value := os.Getenv("OAC_WRITE_AUDIT_RETENTION") + if value == "" { + return 90 * 24 * time.Hour, nil + } + duration, err := time.ParseDuration(value) + if err != nil || duration < time.Hour { + return 0, errors.New("OAC_WRITE_AUDIT_RETENTION must be a duration of at least 1h") + } + return duration, nil +} + +func runWriteAuditCleanup(ctx context.Context, s writeAuditPruner, retention time.Duration, metrics *coremetrics.Service) { + if metrics != nil { + defer metrics.StopJob("audit_cleanup") + } + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + pruneCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + count, err := s.DeleteExpiredWriteOperations(pruneCtx, time.Now().Add(-retention), 1000) + cancel() + reportCleanupResult(metrics, "audit_cleanup", count, err) + if err != nil && ctx.Err() == nil { + log.Ctx(ctx).Warn("Write audit retention cleanup failed") + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/services/agents-api/cmd/server/write_audit_test.go b/services/core/cmd/server/write_audit_test.go similarity index 100% rename from services/agents-api/cmd/server/write_audit_test.go rename to services/core/cmd/server/write_audit_test.go diff --git a/services/core/cmd/specification-contract/main.go b/services/core/cmd/specification-contract/main.go new file mode 100644 index 000000000..9d52abc27 --- /dev/null +++ b/services/core/cmd/specification-contract/main.go @@ -0,0 +1,35 @@ +// specification-contract maintains the generated node installer projection. +package main + +import ( + "flag" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" + "os" + "strings" +) + +func main() { + write := flag.Bool("write", false, "update deploy/install/node_spec.py from the repository root") + flag.Parse() + projection := providers.PythonDeploymentContract() + if !*write { + fmt.Println(projection) + return + } + path := "deploy/install/node_spec.py" + raw, err := os.ReadFile(path) + if err != nil { + panic(err) + } + source := string(raw) + start := strings.Index(source, "# BEGIN GENERATED DEPLOYMENT CONTRACT") + end := strings.Index(source, "# END GENERATED DEPLOYMENT CONTRACT") + if start < 0 || end < start { + panic("missing generated contract markers") + } + end += len("# END GENERATED DEPLOYMENT CONTRACT") + if err = os.WriteFile(path, []byte(source[:start]+projection+source[end:]), 0644); err != nil { + panic(err) + } +} diff --git a/services/agents-api/credentials.md b/services/core/credentials.md similarity index 100% rename from services/agents-api/credentials.md rename to services/core/credentials.md diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/core/deploy/claude/Dockerfile similarity index 100% rename from services/agents-api/deploy/claude/Dockerfile rename to services/core/deploy/claude/Dockerfile diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md new file mode 100644 index 000000000..587ca9c1b --- /dev/null +++ b/services/core/deploy/claude/README.md @@ -0,0 +1,92 @@ +# Claude Code on the dedicated Runtime + +Core is independently deployed. Each managed Session receives one Docker Runtime +containing the daemon, pinned Claude Agent SDK/native harness and local workspace. +The SDK owns the model/tool loop. Public clients use the same Agents API contract. + +## Build and configure + +The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) +builds the image. The bundle pins SDK `0.3.269` and native Claude Code `2.1.269`. The Dockerfile pins +Node's Linux amd64 manifest. Keep the exported SDK bundle immutable. Configure +Core's database-owned managed deployment with the resulting immutable Runtime +image. Existing Docker outer security settings are unchanged; the daemon and +native adapter do not add an inner sandbox. Tools run as UID/GID 1000 and can +access files available to that user. + +Install required system dependencies while building the image/template. Runtime +has no automatic apt installation, sudo or elevated daemon permissions; +`system_packages` is unsupported and missing dependencies cause operation failure. +npm/Python package and setup commands run directly with the existing user's +permissions. The packaged image no longer needs bubblewrap or socat for an inner +sandbox. For native self-hosting and platform limits, see the +[self-hosted guide](../../../../docs/getting-started/self-hosted.md#platforms). + +Set `OAC_DEFAULT_HARNESS=claude_sdk`. Configure the deployment default model provider +with the Core key, in Web or through Core's API: + +```sh +curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/claude_sdk/model-provider \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d '{"protocol":"anthropic","base_url":"https://api.anthropic.com","api_key":"REPLACE_WITH_OPERATOR_SECRET"}' +``` + +A Session may instead supply its own `x_agents_core.model_provider`. Use the +endpoint and model supported by your actual provider. Do not bake keys into the +image. Core freezes the bundle in the Session's encrypted snapshot and delivers it +to the adapter; it is not a public Agent field. Ordinary environment projection +does not isolate locally stored credentials from tools running as the same user. +Follow the existing Core setup for independent PostgreSQL credentials, migrations, +API authentication and managed Runtime enrollment. Parsar is not a dependency. + +The supported hosted profile accepts text execution with medium verbosity and +native Bash/Read/Edit and declared public functions with text results. Files and Artifacts use the shared public interfaces. +Check the current qualified engine profile for MCP, subagents and structured +output combinations. Historical hosted-profile acceptance does not qualify native +self-hosted platforms or every feature combination. The existing +`environment:none` function/MCP profile is separate. This is not complete upstream +protocol compatibility. + +## Runtime and adapter rules + +Native self-hosted installations use the same bundle and daemon protocol. +The shared local binding selects the actual workspace. SDK history, home and +scratch remain under `OAC_RUNTIME_HOME/runtime/claude-sdk` for session ownership, +without restricting tools. Authentication remains under `OAC_RUNTIME_HOME/daemon`. +The daemon requires neither nested sandbox privileges nor host security changes. + +The `local_runtime_v2` capability identifies the current workspace and direct MCP +launcher contract. Reject earlier workspace bundles; matching SDK versions alone +do not establish adapter compatibility. +The adapter advertises `local_runtime_v2` after checking the installed bridge and +native binary. Windows additionally needs Git Bash for its Bash tool. Registration +combines that check with the local binding; Core consumes the same readiness +contract on every platform. The profile supports Bash/Read/Edit, preparation, +shared Files/Artifacts, cancellation and history continuation. Other capabilities +remain subject to their actual engine qualification; bypass execution does not +silently qualify a new MCP or function combination. + +Recovery uses the SDK's history APIs. An explicitly supplied native identity must +exist. If Core requires existing history without having recorded an identity, the +adapter accepts only one nonempty native history for the exact bound cwd. Missing, +foreign, ambiguous or metadata-only history rejects before model input. The Runtime +volume and shared Environment/Session binding establish ownership; this lookup +cannot select another Session's home or infer ownership from a model response. + +Claude hosted functions compose the existing SDK function bridge with the common +workspace execution profile. Only declared function tools and the verified native tool +inventory are available. The bundle advertises this combination separately from +basic workspace execution; function preparations require that verified combination. +Service-origin hosted MCP remains unqualified; Environment Plugin declarations +use the separately qualified initialization path above. Function callbacks do not change file, +credential, history, subagent or network authority. + +## Adding another native engine + +Follow [Add a native Harness](../../../../contracts/agents-api/harness-onboarding.md). +The Claude adapter is one of its [native references](../../../../contracts/agents-api/harness-onboarding.md#native-references): +it implements the shared `agent.ExecutorFactory`, `Executor` and `Turn` +interfaces and registers them in +[`cli/claude_sdk.go`](../../../../apps/daemon/internal/cli/claude_sdk.go). +Acceptance requirements are in +[Harness integration](../../../../contracts/agents-api/harnesses.md#acceptance-checklist). diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/core/deploy/codex/Dockerfile similarity index 100% rename from services/agents-api/deploy/codex/Dockerfile rename to services/core/deploy/codex/Dockerfile diff --git a/services/agents-api/deploy/codex/README.md b/services/core/deploy/codex/README.md similarity index 100% rename from services/agents-api/deploy/codex/README.md rename to services/core/deploy/codex/README.md diff --git a/services/agents-api/deploy/codex/seccomp.LICENSE b/services/core/deploy/codex/seccomp.LICENSE similarity index 100% rename from services/agents-api/deploy/codex/seccomp.LICENSE rename to services/core/deploy/codex/seccomp.LICENSE diff --git a/services/agents-api/deploy/codex/seccomp.json b/services/core/deploy/codex/seccomp.json similarity index 100% rename from services/agents-api/deploy/codex/seccomp.json rename to services/core/deploy/codex/seccomp.json diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md new file mode 100644 index 000000000..904d83a74 --- /dev/null +++ b/services/core/deploy/e2b/README.md @@ -0,0 +1,193 @@ +# E2B Runtime deployment + +E2B supports two separate ownership choices. Core-managed `openai_hosted` uses +the deployment-wide E2B Provider. Application-managed `self_hosted` uses the +startup example below; in that path the application creates, renews and destroys +its own sandbox, and Core receives neither the E2B account key nor an allocation +request. The sandbox runs the existing V1 daemon, selected native harness, tools +and workspace together. Codex, Claude Code and MiniMax Code use the same startup +contract and their respective qualified Runtime images. + +This deployment uses OpenAgentCore daemon enrollment, not Codex `exec-server` or Noise. +Public execution and Files/Artifacts continue through Core and the daemon; +E2B commands/files are used only for deployment, initialization and inspection. +A public Session deletion does not destroy an application-owned VM; managed +compute cleanup remains Core's responsibility. + +## Core-managed hosted deployment + +Select E2B in Core's hosted deployment setup and supply the account key and an +immutable `templateID:build_UUID`. One deployment uses one managed Provider; +E2B needs no physical node enrollment. Changing backend requires explicit reset +and confirmed cleanup of every owned allocation, pending creation and snapshot. +Do not infer execution readiness from saved configuration or running compute. + +The pure-Go adapter invokes the packaged official Python SDK helper. The Core +image and native installation include its runtime dependencies; configuring E2B +does not require installing Python or pip on the server. The account key is +write-only server configuration, passed to the helper over stdin. It never enters +a template, command argument, inherited environment, receipt or public response. + +Keep the helper's private receipt directory on durable storage, owned by the Core +service user with mode 0700. Manual deployments using the default non-root Core +image must give its service UID ownership of that directory. SDK connection +credentials and one-shot allocation claims are stored there; they are not Core +execution state. Preserve them across upgrades and failures. An empty cloud +lookup cannot settle an unknown Create, and inspection never creates, resumes +or reboots compute. Initialization uncertainty requires reclaiming the original +allocation rather than replaying startup. See the [helper contract](../../tools/e2b-provider/README.md). + +Rebuild old templates with this directory's `build-template.py` before managed +use: it includes protected `init.py` and `managed_init.py`. The latter consumes +Core's existing managed daemon auth profile, while application-managed startup +continues to use the executor credential flow below. Both reuse the same daemon, +native harnesses and colocated workspace. A qualified combined Runtime image can +support several harnesses; provider selection is independent of harness choice. + +## Build the packaged Runtime + +Use Python 3.12+, Docker and a qualified Linux amd64 Runtime image containing the +environment-aware daemon `connect` command. Keep keys and build outputs outside +the checkout, in private directories. Install the pinned SDK from this directory: + +```sh +python -m venv "$HOME/.oac/build/e2b-sdk" +"$HOME/.oac/build/e2b-sdk/bin/pip" install -r services/core/deploy/e2b/requirements.txt +"$HOME/.oac/build/e2b-sdk/bin/python" services/core/deploy/e2b/build-template.py \ + --image sha256:QUALIFIED_RUNTIME_IMAGE_DIGEST \ + --name your-runtime-build \ + --api-key-file "$HOME/.oac/secrets/e2b.key" \ + --output "$HOME/.oac/build/e2b-template.json" +``` + +The builder preserves the existing image's binaries, native configuration and +private workspace layout. Its `template` output is an immutable +`templateID:build_UUID`; use that exact value. The build must qualify every harness it advertises; a combined Runtime image +can include several harnesses. No E2B account key, executor key or model credential belongs in a +build, template environment, metadata, command argument or log. The builder gives +traversable modes only to the public archive ancestors it creates (`usr`, +`usr/local`, `etc`). Runtime file and directory modes, private build contexts, +key inputs and the umask of its output stay unchanged, including under umask 077. + +System dependencies must be installed when building the template. The builder may +use root during image construction, but the running daemon remains UID/GID 1000 +with no automatic apt, sudo or privilege escalation. Runtime `system_packages` +is unsupported; missing dependencies fail their consuming operation. The template +no longer installs bubblewrap or socat for inner sandboxing. + +## Application-managed: start an existing self-hosted Environment + +Create a public `self_hosted` Environment through Core and retain its ID and exact +returned `remote_url`. Obtain an authorized connect-only executor key scoped to +that Environment (or its owning principal) through the operator credential flow. +The key JSON is `{"key_id":"UUID","executor_token":"SECRET"}` with an optional +`environment_id` restriction. Store both this JSON and the separate E2B API key +in private files with mode `0600`. + +The current packaged profile uses public `/workspace`, backed by +`/environment/workspace`. The remote endpoint must be reachable from the VM; +use the returned `wss://.../api/v1/agent-daemon/ws` unchanged. The native profile +comes from the image, and model credentials arrive through authenticated Core +execution. Managed startup uses the separate [Runtime bootstrap contract](../../../../docs/runtime-bootstrap.md). + +Generate and retain an application launch UUID once. `launch.py` is a thin SDK +example, not a service or a replacement lifecycle owner: + +```sh +"$HOME/.oac/build/e2b-sdk/bin/python" services/core/deploy/e2b/launch.py \ + --template 'TEMPLATE_ID:BUILD_UUID' \ + --remote-url 'RETURNED_REMOTE_URL' \ + --environment-id 'RETURNED_ENVIRONMENT_UUID' \ + --launch-id 'YOUR_APPLICATION_LAUNCH_UUID' \ + --executor-key-file "$HOME/.oac/secrets/executor-key.json" \ + --api-key-file "$HOME/.oac/secrets/e2b.key" \ + --record "$HOME/.oac/runtimes/YOUR_APPLICATION_LAUNCH_UUID.json" \ + --timeout 7200 +``` + +Choose a lease supported by your E2B account and renew it before expiry. The +example creates an exclusive private launch record before Create, stores the +returned sandbox ID before startup, and sets `on_timeout=kill` with auto-resume +disabled. Metadata contains only the application launch ID and Environment ID. +It never repeats Create/start, replaces a sandbox or deletes failure evidence. +Reusing the record path rejects before any cloud call. Do not bypass that guard +by supplying a new path after an uncertain result. + +## Inspect, renew and destroy + +The application remains responsible for the lease and cleanup, including after +Session deletion or daemon failure. These SDK calls use the retained exact ID +and do not connect to, resume or recreate a sandbox: + +```python +import json +from pathlib import Path +from e2b import Sandbox + +record = json.loads(Path('/private/launch.json').read_text()) +api_key = Path('/private/e2b.key').read_text().strip() +sandbox_id = record['sandbox_id'] +info = Sandbox.get_info(sandbox_id, api_key=api_key) +assert info.metadata['oac_launch_id'] == record['launch_id'] +assert info.metadata['oac_environment_id'] == record['environment_id'] +Sandbox.set_timeout(sandbox_id, 7200, api_key=api_key) # When renewing the live VM. +# When the application is finished, or explicitly abandons this allocation: +Sandbox.kill(sandbox_id, api_key=api_key) +``` + +If Create's response was lost before its ID was saved, discover candidates using +`Sandbox.list(query=SandboxQuery(metadata={'oac_launch_id': launch_id}), +api_key=api_key)`, importing `SandboxQuery` from `e2b`. Consume pages while +`paginator.has_next` via `paginator.next_items()`. Verify both metadata fields +against the private record, retain every matching provider ID, and explicitly +inspect or destroy those allocations. An empty lookup is not permission to retry +an uncertain Create. Do not select an arbitrary candidate or rotate its identity. + +An uncertain startup result requires inspection of the same VM or explicit +cleanup. Root-only `/root/.oac/e2b/launch.json` records the startup claim; +`ready.json` records only successful process handoff (`daemon_started`), even if +the daemon subsequently exits. Neither proves enrollment, native readiness or a +successful Turn. Check public Core Environment status and the private daemon log +at `/home/runtime/.oac/daemon/default/daemon.log`. The daemon's separate +`/home/runtime/.oac/daemon/environment.json` records the verified +Environment/Session binding. Keep these records and native history on failure. +Do not rerun `init.py`; it refuses any claimed attempt, including interrupted ones. +The SDK's `Sandbox.connect` can resume paused sandboxes, so it is not used as an +automatic recovery/inspection step here. VM expiry destroys volatile history; +never claim a replacement VM recovered the original Session. + +## Startup and security boundary + +The protected image initializer uses the image's explicit environment, restores +E2B-finalized executable/service permissions, locks the unused privileged `user` +account, and binds `/workspace`. It writes the executor key to a mode-`0600` file +inside the protected daemon directory, then starts the existing daemon as UID +1000 with that file path. The input is removed before startup. No bearer enters +the daemon's argv or inherited environment. Enrollment and immutable local binding +remain daemon responsibilities; startup does not invent device/Session IDs. + +E2B clears `/run` at boot, so startup records live under `/root/.oac/e2b`. +The E2B VM is the outer isolation boundary. The daemon and native harness add no +inner filesystem, permission or network sandbox; tools have UID 1000's access to +Runtime state. Verify the actual outer boundary, process cleanup and native +execution for each template. Prior private-file denial results describe the old +inner sandbox and do not qualify the current behavior. +The one-shot receipt cannot be reused to replace the daemon or overwrite history. + +## Verification scope + +Run the focused local tests with the pinned SDK environment: + +```sh +python -m unittest discover -s services/core/deploy/e2b -p '*_test.py' -v +``` + +These are controlled startup-contract tests: input binding, protected key output, +unchanged URL, no secret in argv/environment/record, one-shot claim, and retained +provider ID on unknown outcomes. They do not create billable resources or qualify +E2B security, enrollment or model execution. The [qualification record](../../../../contracts/agents-api/user-managed-runtime-v1.md) +records historical three-harness deployment results and their verification limits, +including shared Core credential lifecycle checks and explicit application-owned +cleanup. `tests/official_user_runtime.py` supplies the shared +public execution checks. The former Core-managed `official_e2b_v1.py` fixture is +retired; its original source and evidence remain in Git history. diff --git a/services/agents-api/deploy/e2b/build-template.py b/services/core/deploy/e2b/build-template.py similarity index 100% rename from services/agents-api/deploy/e2b/build-template.py rename to services/core/deploy/e2b/build-template.py diff --git a/services/agents-api/deploy/e2b/build_template_test.py b/services/core/deploy/e2b/build_template_test.py similarity index 100% rename from services/agents-api/deploy/e2b/build_template_test.py rename to services/core/deploy/e2b/build_template_test.py diff --git a/services/agents-api/deploy/e2b/init.py b/services/core/deploy/e2b/init.py similarity index 100% rename from services/agents-api/deploy/e2b/init.py rename to services/core/deploy/e2b/init.py diff --git a/services/agents-api/deploy/e2b/init_test.py b/services/core/deploy/e2b/init_test.py similarity index 100% rename from services/agents-api/deploy/e2b/init_test.py rename to services/core/deploy/e2b/init_test.py diff --git a/services/agents-api/deploy/e2b/launch.py b/services/core/deploy/e2b/launch.py similarity index 100% rename from services/agents-api/deploy/e2b/launch.py rename to services/core/deploy/e2b/launch.py diff --git a/services/agents-api/deploy/e2b/managed_init.py b/services/core/deploy/e2b/managed_init.py similarity index 100% rename from services/agents-api/deploy/e2b/managed_init.py rename to services/core/deploy/e2b/managed_init.py diff --git a/services/agents-api/deploy/e2b/managed_init_test.py b/services/core/deploy/e2b/managed_init_test.py similarity index 100% rename from services/agents-api/deploy/e2b/managed_init_test.py rename to services/core/deploy/e2b/managed_init_test.py diff --git a/services/agents-api/deploy/e2b/requirements.txt b/services/core/deploy/e2b/requirements.txt similarity index 100% rename from services/agents-api/deploy/e2b/requirements.txt rename to services/core/deploy/e2b/requirements.txt diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/core/deploy/mcode/Dockerfile similarity index 100% rename from services/agents-api/deploy/mcode/Dockerfile rename to services/core/deploy/mcode/Dockerfile diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md new file mode 100644 index 000000000..4768e8cda --- /dev/null +++ b/services/core/deploy/mcode/README.md @@ -0,0 +1,215 @@ +# MiniMax Code Runtime + +MiniMax Code uses the same Core/Runtime execution contract as the other engines. +The text profile supports `environment:none`; the dedicated Docker profile adds +workspace execution and the shared Files/Artifacts path. The historical +workspace qualification (evidence under `~/.parsar/remediation/20260919/mcode-workspace/`) +does not describe the current bypass profile. +Public MCP/functions, image input and native Subagent execution remain outside +this batch. + +## Pinned prerequisite + +Use the official [`@minimax-ai/code`](https://github.com/MiniMax-AI/minimax-code) +package version **0.4.12**, with Node.js 22.x and its native SQLite dependency. The Docker image pins Node.js +22.23.1; the text fixture used 22.22.0. +The inspected upstream source is `33b259bbbeb1c16433390869938191d09bdb0680`. +Install outside the checkout, under a private operator directory in `~/.oac/`. +Check the native install succeeds and `mcode --version` reports exactly 0.4.12. +This profile runs on a trusted execution host. + +Set `OAC_RUNTIME_MCODE_BIN` to that absolute executable and `OAC_RUNTIME_MCODE_AGENTS_API=1` +for the daemon. The opt-in only advertises the profile for the qualified version. +Use the existing authenticated daemon connection and operator device enrollment; +native self-hosted installation uses the same Runtime protocol. See the +[self-hosted guide](../../../../docs/getting-started/self-hosted.md#platforms); MiniMax on Windows remains +unsupported. +Set `OAC_DEFAULT_HARNESS=mcode` in the independent Core deployment. Existing Sessions +retain their engine. Do not expose a new public harness selector. + +## Docker workspace + +The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) +builds the companion from the pinned native source and the Runtime image. + +Configure Core's existing managed Docker provider with the immutable image ID, +`deploy/codex/seccomp.json` and `nested_sandbox: true`. Core, database ownership, +enrollment and the public protocol remain shared. The image supplies the private +`OAC_RUNTIME_MCODE_WORKSPACE=managed` and companion paths; caller Agent options cannot +change them. Public Files and Artifacts use the common bound workspace helpers. + +The native process, ACP Session and six original native tools use the declared +Environment workspace (`/workspace` in the Docker image). The tools connect +through one trusted MCP bridge with the daemon user's ordinary permissions and +no inner sandbox. MCP is internal transport here; its presence alone does not +enable caller-supplied public MCP servers. Native project discovery uses that +same workspace; adapter-owned configuration and history remain in the private +Session data directory. Exact native-ID continuation remains required; recovery +without a recorded ID fails closed. Native Bash observations become public +`command_execution` items after command arguments arrive. Their text output and +status are retained; absent native exit code/duration stay unknown. The private +MCP server and file/skill/task utilities are not invented public MCP or function +calls. + +## Provider configuration + +Supply an Anthropic-compatible bundle with the model's context and output limits, +either per Session as `x_agents_core.model_provider` or as the deployment default, +set with the Core key in Web or through Core's API: + +```sh +curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/mcode/model-provider \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d '{"protocol":"anthropic","base_url":"https://api.moonshot.cn/anthropic","api_key":"","context_window":64000,"max_output_tokens":4096}' +``` + +Core maps the bundle to the native custom provider for the Session's exact +`agent.model`, with these limits. The adapter selects it explicitly; it does not +use a native account fallback. +Use the real provider endpoint for the selected credential. Test proxies may +forward requests unchanged and capture tool names/status, but must not synthesize +model responses or log keys. + +MiniMax M2 Chat Completions may return inline `` content. The pinned native +Harness does not expose the provider's `reasoning_split` option; the adapter does +not guess which response text to remove. Anthropic Messages supplies distinct +thinking blocks. File read/write utilities also have no qualified public Item +mapping; only actual Bash calls become `command_execution`. + +## Execution boundaries + +Each API Session uses a separate native state directory. Workspace execution +uses the declared Environment directory; text-only execution uses a private cwd. +The execution child inherits only process and model-network essentials; it does +not inherit Core/daemon tokens or arbitrary Node startup configuration. The +adapter owns its native config, instructions and home and disables external +skills, delegated work, web search, builtin file/shell tools, browser tools, +mcode-tools and native goals. The workspace profile uses its tool bridge without +sandbox isolation. Tools can read any local state available to the launching +user. The outer Environment owns managed isolation; daemon network modes do not +add another network boundary. + +Provide system dependencies at image/template build time or on the self-hosted +machine before execution. Runtime does not run apt or sudo or elevate daemon +permissions. `system_packages` is unsupported; missing dependencies fail the +operation requiring them. npm/Python packages and setup retain direct execution +with the user's existing permissions. + +Workspace Skills use the frozen installation's exact names and native `skill` +loader. Session-local links resolve to the installed package directories; +external discovery stays disabled. The text-only profile has no selected Skills. +Task utilities such as `task_query`, `task_output` and `task_stop` cannot create +work and enforce native Session ownership. Auxiliary native title requests are +internal bookkeeping. Do not equate these with public function or workspace tools. + +ACP `mcode/session/steer` acknowledges acceptance for the active native Turn, +separately from the transport write. It is not a promise that the model consumed +that input before cancellation. Unknown outcomes are never automatically replayed. +Cancellation waits for process-group exit and output settlement. Public slash +text remains a model message instead of invoking native ACP operator commands. + +Cold continuation requires the exact persisted native Session ID and matching +workspace cwd (or private cwd for text-only execution). Missing/foreign history +fails; recovery by guessing an ID from native session listings is not qualified. +Public usage breakdown is unavailable because native ACP context occupancy and +cumulative cost are not per-Turn usage. + +### Adapter rules + +MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for +`environment:none` text execution. It reuses the shared lifecycle without public +workspace, functions or MCP. Enabled Subagents use the separately qualified +common observation path below. Native configuration disables +file/shell authority and external +capability discovery; the child receives a private Session home and a restricted +environment. Active-input application requires a native ACP receipt. Normal +Turns retain one ACP connection and native Session. Cancellation requires native +root and child completion evidence before reuse. Without a qualified root-state +reader, the ACP cancellation response is insufficient: stop the invalid native +process and wait for its exit before settling the Turn as non-reusable. Common +Runtime recovery then loads the exact owned native history in a new Executor. Do not infer history IDs or qualify hosted execution from this text +profile. See [Acceptance](#acceptance). + +MiniMax companion readiness uses private protocol 2; old companions are rejected +even when the upstream version matches. Cancellation retires the executor and +settles its native workers and detached Bash groups before acknowledgement; +later work recovers the same native history in a new owner without replay. + +The MiniMax workspace profile builds one CLI from the fixed upstream source and +lockfile through the existing companion packaging path, and connects native +workspace tools through its standard MCP client. The process and native Session +share the declared workspace directory, including for cold continuation. A +trusted adapter-owned bridge runs the original six tool implementations with the +launching user's ordinary permissions. It adds no inner sandbox on any platform. +Keep native history in the Session data directory and native execution and +Files/Artifacts bound to the same Environment workspace. Core and shared file +helpers remain engine neutral. This internal MCP transport does not admit public +MCP configuration. Record the upstream revision, native admission patch hashes +and worker-source provenance. The bounded patch checks the shared +descendant-task limit inside the existing native SQLite admission transaction +before start, without another scheduler. ACP initialization must acknowledge the +applied limit before input. The native tool catalog selects the same admitted +workspace tools for every child, not only the root's configured profile; this is +tool selection, not filesystem isolation. Only the Session's authorized internal +workspace MCP entry crosses the native child selector; this does not grant +external MCP access. Initialization must acknowledge the admitted tool inventory +before input as well. Subagent reads use the Session's native database; the +daemon does not protect it from other tools running as the same user. +Multi-agent workspace execution installs only the existing authorized workspace +MCP entry in that private native configuration so children inherit the same +tools; public MCP and Environment-origin MCP combinations remain separately +qualified. Complete the hosted [acceptance +checklist](../../../../contracts/agents-api/harnesses.md#acceptance-checklist) +before enabling hosted execution. The standalone companion uses its own npm +lock; `make check` runs its lifecycle tests and script checks, while its +exact-source Linux build and native qualification for the actual supported +platform and outer deployment are required when the companion changes. +Historical tests of both inner network policies do not qualify the current +bypass implementation. + +## Acceptance + +Recorded results below are historical evidence for their exact binaries and +profiles, not qualification of the current native platforms or bypass behavior. + +The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, +raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. +Provide private `OAC_TEST_MCODE_REAL_OPTIONS` (the provider object above plus the +`model` string), `OAC_RUNTIME_MCODE_BIN`, `OAC_TEST_NATIVE_DAEMON_BIN`, +`OAC_TEST_NATIVE_PROOF_DIR`, `OAC_TEST_OFFICIAL_SDK_PYTHON` and +`OAC_TEST_DATABASE_URL`, then run: + +```sh +go test ./services/core/internal/store \ + -run '^TestNativeMCodePublicExecution$' -count=1 -v -timeout=15m +``` + +It verifies real text execution, same-Turn steering and durable input receipt, +daemon cold restart with the same native ID, ordinary slash text, foreign-tenant +rejection, cancellation and subsequent continuation. Run independently with real +Kimi and MiniMax options. This fixture creates only operator device credentials +privately; all tested Sessions and inputs enter through public HTTP. + +`TestNativeMCodeHistoryIsolation` additionally takes +`OAC_TEST_MCODE_FOREIGN_NATIVE_ID` from a successful public run and verifies rejection +in another private native home, plus rejection of a nonexistent history ID. Run it +in the daemon's `internal/agent/mcode` package with the same private native/provider +options. It must fail before model input, without a replacement native Session. + +On 2026-09-19, the public fixture passed independently with real Kimi K3 and +MiniMax M2.7 APIs. Both exercised execution, native steering receipts, daemon +restart, history continuation, tenant rejection and cancel/continue. Native +missing/foreign history rejection passed separately. Credential scans found the +provider key only in each private mode-0600 native config, not in logs, public +results or native history. Product ACP regression uses the same 0.4.12 package +and covers new/resume, model/instruction refresh, Skill discovery and MCP using +its existing synthetic provider fixture. + +That text acceptance used an in-process Core HTTP server and a separate real +daemon. The subsequent Docker workspace qualification +(`~/.parsar/remediation/20260919/mcode-workspace/`) used independently built +Core binaries, its own database and managed Runtime. +It covers public Files/Artifacts, cancellation, reconnect, exact-history recovery +and isolation with real Kimi and MiniMax APIs. Read its recorded Kimi continuation +limitation: new model-issued commands are not automatic recovery replay. Neither +acceptance establishes complete official protocol compatibility. diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/core/deploy/microsandbox/README.md similarity index 100% rename from services/agents-api/deploy/microsandbox/README.md rename to services/core/deploy/microsandbox/README.md diff --git a/services/agents-api/internal/adminaudit/context.go b/services/core/internal/adminaudit/context.go similarity index 100% rename from services/agents-api/internal/adminaudit/context.go rename to services/core/internal/adminaudit/context.go diff --git a/services/agents-api/internal/api/admin_agents_routes.go b/services/core/internal/api/admin_agents_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_agents_routes.go rename to services/core/internal/api/admin_agents_routes.go diff --git a/services/agents-api/internal/api/admin_environment_templates_routes.go b/services/core/internal/api/admin_environment_templates_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_environment_templates_routes.go rename to services/core/internal/api/admin_environment_templates_routes.go diff --git a/services/agents-api/internal/api/admin_files_routes.go b/services/core/internal/api/admin_files_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_files_routes.go rename to services/core/internal/api/admin_files_routes.go diff --git a/services/core/internal/api/admin_history.go b/services/core/internal/api/admin_history.go new file mode 100644 index 000000000..819c26087 --- /dev/null +++ b/services/core/internal/api/admin_history.go @@ -0,0 +1,69 @@ +package api + +import ( + "net/http" + "net/url" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// @Summary Query committed administrator mutations +// @Description Core key only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id query string false "Target Project ID" +// @Param resource_type query string false "Resource type" +// @Param resource_id query string false "Resource ID" +// @Param action query string false "Mutation action" +// @Param created_after query string false "Inclusive RFC3339 timestamp" +// @Param created_before query string false "Exclusive RFC3339 timestamp" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(50) +// @Param after query string false "Opaque next_cursor from the preceding page" +// @Success 200 {object} store.AdminAuditPage +// @Failure 400,401,500 {object} CoreErrorResponse +// @Router /core/v1/audit-log [get] +func (h *Handler) listAdminAudit(w http.ResponseWriter, r *http.Request) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + for name, entries := range values { + switch name { + case "project_id", "resource_type", "resource_id", "action", "created_after", "created_before", "limit", "after": + default: + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if len(entries) != 1 || entries[0] == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + filter := store.AdminAuditFilter{ProjectID: values.Get("project_id"), ResourceType: values.Get("resource_type"), ResourceID: values.Get("resource_id"), Action: values.Get("action"), After: values.Get("after"), Limit: 50} + if limit := values.Get("limit"); limit != "" { + filter.Limit, err = strconv.Atoi(limit) + if err != nil || filter.Limit < 1 || filter.Limit > 100 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + filter.CreatedAfter, err = adminSummaryTime(r, "created_after") + if err != nil { + writeStoreError(w, r, err) + return + } + filter.CreatedBefore, err = adminSummaryTime(r, "created_before") + if err != nil { + writeStoreError(w, r, err) + return + } + page, err := h.adminManagement.ListAdminAudit(r.Context(), filter) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, page) +} diff --git a/services/agents-api/internal/api/admin_resources.go b/services/core/internal/api/admin_resources.go similarity index 98% rename from services/agents-api/internal/api/admin_resources.go rename to services/core/internal/api/admin_resources.go index 3519b89ce..e0fce76dd 100644 --- a/services/agents-api/internal/api/admin_resources.go +++ b/services/core/internal/api/admin_resources.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/admin_resources_test.go b/services/core/internal/api/admin_resources_test.go similarity index 96% rename from services/agents-api/internal/api/admin_resources_test.go rename to services/core/internal/api/admin_resources_test.go index 14cee34d2..2f22e6441 100644 --- a/services/agents-api/internal/api/admin_resources_test.go +++ b/services/core/internal/api/admin_resources_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const managementProjectID = "22222222-2222-4222-8222-222222222222" diff --git a/services/agents-api/internal/api/admin_runtime.go b/services/core/internal/api/admin_runtime.go similarity index 97% rename from services/agents-api/internal/api/admin_runtime.go rename to services/core/internal/api/admin_runtime.go index e5c1868af..6b30b4048 100644 --- a/services/agents-api/internal/api/admin_runtime.go +++ b/services/core/internal/api/admin_runtime.go @@ -4,8 +4,8 @@ import ( "context" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" ) type AdminRuntimeObservation struct { diff --git a/services/agents-api/internal/api/admin_runtime_test.go b/services/core/internal/api/admin_runtime_test.go similarity index 97% rename from services/agents-api/internal/api/admin_runtime_test.go rename to services/core/internal/api/admin_runtime_test.go index 245439e9b..a0678aa8c 100644 --- a/services/agents-api/internal/api/admin_runtime_test.go +++ b/services/core/internal/api/admin_runtime_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/api/admin_session_archive.go b/services/core/internal/api/admin_session_archive.go new file mode 100644 index 000000000..3e8816bce --- /dev/null +++ b/services/core/internal/api/admin_session_archive.go @@ -0,0 +1,71 @@ +package api + +import ( + "context" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +// WithSessionArchive binds the execution owner; management reads use the ordinary Store. +func WithSessionArchive(archive func(context.Context, string, string, uint64) (store.ManagedSessionArchive, error)) Option { + return func(h *Handler) { h.adminArchive = archive } +} + +type AdminSessionArchiveRequest struct { + ExpectedGeneration uint64 `json:"expected_generation"` +} + +// @Summary Release a managed Session's execution resources while retaining history +// @Description Core key only. Requires the current deployment generation; no maintenance mode is required. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. +// @Tags Core Administration +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param session_id path string true "Session ID" +// @Param body body api.AdminSessionArchiveRequest true "Current deployment generation" +// @Success 200 {object} store.ManagedSessionArchive +// @Failure 400,401,404,409,413,500,503 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [post] +func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBodyLimit(w, r, 4096, "Archive request is too large.") + if !ok { + return + } + var input AdminSessionArchiveRequest + if decodeInputObject(raw, &input, "expected_generation") != nil || input.ExpectedGeneration == 0 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if h.adminArchive == nil { + writeStoreError(w, r, store.ErrEnvironmentUnavailable) + return + } + result, err := h.adminArchive(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), input.ExpectedGeneration) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary Retrieve a managed Session's resource cleanup state +// @Description Core key only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param session_id path string true "Session ID" +// @Success 200 {object} store.ManagedSessionArchive +// @Failure 400,401,404,500,503 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [get] +func (h *Handler) adminGetSessionArchive(w http.ResponseWriter, r *http.Request) { + result, err := h.adminManagement.GetManagedSessionArchive(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} diff --git a/services/core/internal/api/admin_session_archive_test.go b/services/core/internal/api/admin_session_archive_test.go new file mode 100644 index 000000000..5abf5460b --- /dev/null +++ b/services/core/internal/api/admin_session_archive_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type archiveManagementFixture struct { + AdminManagementStore + tenant, session string + generation uint64 + calls int + audited, impersonated bool + err error +} + +func (s *archiveManagementFixture) ArchiveManagedSession(ctx context.Context, tenant, session string, generation uint64) (store.ManagedSessionArchive, error) { + s.calls++ + s.tenant, s.session, s.generation = tenant, session, generation + source, ok := adminaudit.FromContext(ctx) + s.audited = ok && source.ProjectID == managementProjectID && source.CredentialID != "" && source.RequestID != "" + s.impersonated = ctx.Value(principalContextKey{}) != nil + return store.ManagedSessionArchive{SessionID: session, EnvironmentID: "environment", State: "cleanup_pending"}, s.err +} + +func (s *archiveManagementFixture) GetManagedSessionArchive(_ context.Context, tenant, session string) (store.ManagedSessionArchive, error) { + s.calls++ + s.tenant, s.session = tenant, session + return store.ManagedSessionArchive{SessionID: session, EnvironmentID: "environment", State: "released"}, s.err +} + +func TestAdminSessionArchiveAuthorityAndValidation(t *testing.T) { + key := callerBinding() + auth, _ := NewAuthenticator([]APIKey{key}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + fixture := &archiveManagementFixture{} + h, err := NewHandler(&recordingStore{}, auth, "codex", WithProjectAPIKeys(managementProjectStore(key), admin), WithAdminManagement(fixture), WithSessionArchive(fixture.ArchiveManagedSession)) + if err != nil { + t.Fatal(err) + } + path := "/core/v1/projects/" + managementProjectID + "/sessions/11111111-1111-4111-8111-111111111111/archive" + for _, body := range []string{`{}`, `{"expected_generation":null}`, `{"expected_generation":0}`, `{"expected_generation":-1}`, `{"expected_generation":1.5}`, `{"expected_generation":"1"}`, `{"Expected_Generation":1}`} { + if w := projectKeyHTTP(h, http.MethodPost, path, "admin", body); w.Code != 400 { + t.Fatalf("invalid archive body accepted: %s: %d %s", body, w.Code, w.Body) + } + } + for _, token := range []string{"", "caller", "issued-project-key"} { + for _, method := range []string{http.MethodGet, http.MethodPost} { + if w := projectKeyHTTP(h, method, path, token, `{"expected_generation":1}`); w.Code != 401 { + t.Fatalf("archive authorized %q: %d", token, w.Code) + } + } + } + if fixture.calls != 0 { + t.Fatal("invalid request reached store") + } + w := projectKeyHTTP(h, http.MethodPost, path, "admin", `{"expected_generation":2}`) + var result store.ManagedSessionArchive + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.State != "cleanup_pending" { + t.Fatalf("archive: %d %s", w.Code, w.Body) + } + if fixture.tenant != key.TenantID || fixture.generation != 2 || !fixture.audited || fixture.impersonated { + t.Fatalf("incorrect administrative target: %+v", fixture) + } + w = projectKeyHTTP(h, http.MethodGet, path, "admin", "") + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.State != "released" { + t.Fatalf("archive status: %d %s", w.Code, w.Body) + } + for _, failure := range []struct { + err error + status int + }{{store.ErrSandboxDeploymentConflict, 409}, {store.ErrNotFound, 404}, {store.ErrInvalidInput, 400}} { + fixture.err = failure.err + if w := projectKeyHTTP(h, http.MethodPost, path, "admin", `{"expected_generation":2}`); w.Code != failure.status { + t.Fatalf("archive error: %d %s", w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/core/internal/api/admin_sessions_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_sessions_routes.go rename to services/core/internal/api/admin_sessions_routes.go diff --git a/services/agents-api/internal/api/admin_skills_routes.go b/services/core/internal/api/admin_skills_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_skills_routes.go rename to services/core/internal/api/admin_skills_routes.go diff --git a/services/core/internal/api/admin_summary.go b/services/core/internal/api/admin_summary.go new file mode 100644 index 000000000..852217029 --- /dev/null +++ b/services/core/internal/api/admin_summary.go @@ -0,0 +1,199 @@ +package api + +import ( + "context" + "net/http" + "sort" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type AdminSessionCounts struct { + Total int64 `json:"total"` + Idle int64 `json:"idle"` + InProgress int64 `json:"in_progress"` + RequiresAction int64 `json:"requires_action"` + Failed int64 `json:"failed"` +} +type AdminUsageCoverage struct { + MeasuredSessions int64 `json:"measured_sessions"` + TotalSessions int64 `json:"total_sessions"` + Ratio *float64 `json:"ratio"` +} +type AdminSummaryRow struct { + ProjectID string `json:"project_id"` + KeyID *string `json:"key_id"` + AgentID *string `json:"agent_id"` + Assets *store.AdminAssetCounts `json:"assets"` + Sessions AdminSessionCounts `json:"sessions"` + Usage v1.TokenUsage `json:"usage"` + Coverage AdminUsageCoverage `json:"coverage"` + LastActiveAt *int64 `json:"last_active_at"` +} +type AdminSummaryResponse struct { + Data []AdminSummaryRow `json:"data"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + +func adminSummaryTime(r *http.Request, name string) (*time.Time, error) { + values, ok := r.URL.Query()[name] + if !ok { + return nil, nil + } + if len(values) != 1 || values[0] == "" { + return nil, store.ErrInvalidInput + } + value, err := time.Parse(time.RFC3339Nano, values[0]) + if err != nil { + return nil, store.ErrInvalidInput + } + return &value, nil +} + +// @Summary Summarize resource counts and Session usage by Project, Agent or creator key +// @Description Core key only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id query string false "One API Project" +// @Param group_by query string false "Grouping" Enums(project,agent,key) default(project) +// @Param created_after query string false "Inclusive RFC3339 Session creation time" +// @Param created_before query string false "Exclusive RFC3339 Session creation time" +// @Param after query string false "Last Project ID in preceding page" +// @Param limit query int false "Number of Projects" minimum(1) maximum(100) default(20) +// @Param order query string false "Project order" Enums(asc,desc) default(desc) +// @Success 200 {object} api.AdminSummaryResponse +// @Failure 400,401,404,500 {object} CoreErrorResponse +// @Router /core/v1/summary [get] +func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r, "project_id", "group_by", "created_after", "created_before") + if !ok { + return + } + group := r.URL.Query().Get("group_by") + if group == "" { + group = "project" + } + if group != "project" && group != "agent" && group != "key" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + after, err := adminSummaryTime(r, "created_after") + if err != nil { + writeStoreError(w, r, err) + return + } + before, err := adminSummaryTime(r, "created_before") + if err != nil { + writeStoreError(w, r, err) + return + } + if after != nil && before != nil && !after.Before(*before) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if group == "agent" && r.URL.Query().Get("project_id") == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + var projects store.ProjectPage + if projectID := r.URL.Query().Get("project_id"); projectID != "" { + if options.after != "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + var binding store.ProjectBinding + binding, err = h.resolveAdminProject(ctx, projectID) + projects = store.ProjectPage{Data: []store.Project{binding.Project}} + } else { + projects, err = h.listAdminProjects(ctx, options.after, options.limit, options.ascending) + } + if err != nil { + writeStoreError(w, r, err) + return + } + response := AdminSummaryResponse{Data: []AdminSummaryRow{}, HasMore: projects.HasMore} + for _, project := range projects.Data { + groups := map[string]*AdminSummaryRow{} + if group == "project" { + groups[""] = &AdminSummaryRow{ProjectID: project.ID} + } + counts, err := h.adminManagement.ReadAdminSummary(ctx, project.TenantID, store.AdminSummaryFilter{CreatedAfter: after, CreatedBefore: before}, func(session store.Session, creationKeyID *string) error { + projected, err := sessionResponse(session, h.executorURL) + if err != nil { + return err + } + groupID := "" + if group == "agent" { + groupID = projected.Agent.ID + } else if group == "key" && creationKeyID != nil { + groupID = *creationKeyID + } + row := groups[groupID] + if row == nil { + row = &AdminSummaryRow{ProjectID: project.ID} + if group == "agent" { + id := groupID + row.AgentID = &id + } else if group == "key" { + row.KeyID = creationKeyID + } + groups[groupID] = row + } + row.Sessions.Total++ + switch projected.Status { + case "idle": + row.Sessions.Idle++ + case "in_progress": + row.Sessions.InProgress++ + case "requires_action": + row.Sessions.RequiresAction++ + case "failed": + row.Sessions.Failed++ + } + if row.LastActiveAt == nil || projected.LastActiveAt > *row.LastActiveAt { + at := projected.LastActiveAt + row.LastActiveAt = &at + } + row.Coverage.TotalSessions++ + if usage := projected.Usage; usage != nil { + row.Coverage.MeasuredSessions++ + row.Usage.InputTokens += usage.InputTokens + row.Usage.OutputTokens += usage.OutputTokens + row.Usage.TotalTokens += usage.TotalTokens + row.Usage.InputTokensDetails.CachedTokens += usage.InputTokensDetails.CachedTokens + row.Usage.OutputTokensDetails.ReasoningTokens += usage.OutputTokensDetails.ReasoningTokens + } + return nil + }) + if err != nil { + writeStoreError(w, r, err) + return + } + if group == "project" { + groups[""].Assets = &counts + } + ids := make([]string, 0, len(groups)) + for id := range groups { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + row := groups[id] + if row.Coverage.TotalSessions > 0 { + ratio := float64(row.Coverage.MeasuredSessions) / float64(row.Coverage.TotalSessions) + row.Coverage.Ratio = &ratio + } + response.Data = append(response.Data, *row) + } + } + if projects.HasMore && len(projects.Data) > 0 { + response.NextCursor = projects.Data[len(projects.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/admin_vaults_routes.go b/services/core/internal/api/admin_vaults_routes.go similarity index 100% rename from services/agents-api/internal/api/admin_vaults_routes.go rename to services/core/internal/api/admin_vaults_routes.go diff --git a/services/core/internal/api/agents.go b/services/core/internal/api/agents.go new file mode 100644 index 000000000..e44b65d58 --- /dev/null +++ b/services/core/internal/api/agents.go @@ -0,0 +1,118 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type AgentStore interface { + DeleteAgent(context.Context, string, string) (string, error) + UpdateAgent(context.Context, string, string, store.UpdateAgentInput) (store.SavedAgent, error) + ListAgents(context.Context, string, string, int, bool) (store.AgentPage, error) + CreateAgent(context.Context, string, store.CreateAgentInput) (store.SavedAgent, error) + GetAgent(context.Context, string, string) (store.SavedAgent, error) +} + +// @Summary Create a reusable Agent +// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. As on every Agents API JSON route, a non-JSON Content-Type, invalid UTF-8, malformed JSON, a repeated key at any depth or a non-object root returns invalid_request_error with a null param and the official message before other checks; an empty or null body is {}. Missing, unknown, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id, service origin (omitted or null on HTTP transport is saved as service) and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location, including {}, includes all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. +// @Tags Agents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.CreateAgentRequest true "Reusable Agent configuration" +// @Success 201 {object} v1.SavedAgent +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /agents [post] +func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObject(w, r) + if !ok { + return + } + if writeFieldError(w, metadataTypeError(raw)) || writeFieldError(w, validateSavedAgentBody(raw, savedAgentCreate)) { + return + } + if err := validateSavedCoreInput(raw); err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + return + } + var request v1.CreateAgentRequest + if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + return + } + input, err := resolveSavedAgent(request) + if err != nil { + if !writeFieldError(w, err) { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + } + return + } + agent, err := h.store.CreateAgent(r.Context(), tenantID(r), input) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgentStatus(w, r, agent, http.StatusCreated) +} + +// @Summary Retrieve a reusable Agent +// @Description Reads the saved resource owned by the authenticated tenant, independently of execution Sessions. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Success 200 {object} v1.SavedAgent +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [get] +func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { + agent, err := h.lookupAgent(r.Context(), tenantID(r), chi.URLParam(r, "agent_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgent(w, r, agent) +} + +func (h *Handler) respondAgent(w http.ResponseWriter, r *http.Request, agent store.SavedAgent) { + h.respondAgentStatus(w, r, agent, http.StatusOK) +} + +func (h *Handler) respondAgentStatus(w http.ResponseWriter, r *http.Request, agent store.SavedAgent, status int) { + response, err := agentResponse(agent) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, status, response) +} + +func agentResponse(agent store.SavedAgent) (v1.SavedAgent, error) { + var response v1.SavedAgent + if err := json.Unmarshal(agent.Configuration, &response.SavedAgentConfiguration); err != nil { + return response, err + } + response.ID, response.Object = agent.ID, "agent" + response.Metadata = agent.Metadata + response.CreatedAt, response.UpdatedAt = agent.CreatedAt.Unix(), agent.UpdatedAt.Unix() + return response, nil +} + +func (h *Handler) lookupAgent(ctx context.Context, tenant, id string) (store.SavedAgent, error) { + if !validAgentID(id) { + return store.SavedAgent{}, store.ErrNotFound + } + return h.store.GetAgent(ctx, tenant, id) +} + +func validAgentID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed != uuid.Nil +} diff --git a/services/core/internal/api/agents_delete.go b/services/core/internal/api/agents_delete.go new file mode 100644 index 000000000..cdec0832d --- /dev/null +++ b/services/core/internal/api/agents_delete.go @@ -0,0 +1,42 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Delete a reusable Agent +// @Description Deletes only the authenticated tenant's saved configuration. Existing Session snapshots, history and recorded creation retry identities remain independent. Missing and repeated deletion locally return404; exact hosted error and in-flight creation/deletion semantics remain unverified. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Success 200 {object} v1.AgentDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [delete] +func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) { + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Agent deletion does not accept a request body.") + return + } + id := chi.URLParam(r, "agent_id") + if !validAgentID(id) { + writeStoreError(w, r, store.ErrNotFound) + return + } + deleted, err := h.store.DeleteAgent(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.AgentDeleted{ID: deleted, Object: "agent.deleted", Deleted: true}) +} diff --git a/services/core/internal/api/agents_list.go b/services/core/internal/api/agents_list.go new file mode 100644 index 000000000..9244293a1 --- /dev/null +++ b/services/core/internal/api/agents_list.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// @Summary List reusable Agents +// @Description Lists only the authenticated tenant's saved Agents, independently of Sessions. Limit 0 is treated as 1 and larger limits as 100, as observed on the hosted service. The local default is 20; exact upstream default/cap and empty cursor fields remain unverified. An unknown, malformed or foreign after cursor returns not found. +// @Tags Agents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Last Agent ID from the previous page" +// @Param limit query int64 false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SavedAgentList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents [get] +func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r) + if !ok { + return + } + page, err := h.store.ListAgents(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.SavedAgentList{Object: "list", Data: make([]v1.SavedAgent, 0, len(page.Agents)), HasMore: page.NextCursor != ""} + for _, agent := range page.Agents { + item, err := agentResponse(agent) + if err != nil { + writeStoreError(w, r, err) + return + } + response.Data = append(response.Data, item) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/core/internal/api/agents_update.go b/services/core/internal/api/agents_update.go new file mode 100644 index 000000000..b49115aae --- /dev/null +++ b/services/core/internal/api/agents_update.go @@ -0,0 +1,109 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Update a reusable Agent +// @Description Preserves omitted fields and replaces supplied fields using shared saved-configuration validation. Null name/instructions clear; null or empty metadata clears all pairs. Name, metadata and configuration validation errors return invalid_request_error with the official param, using the Agent create rules before the Agent lookup. Existing Session snapshots are unchanged. Empty updates advance updated_at without changing saved fields. Nested replacement/null defaults, model-derived reasoning and exact hosted error behavior remain incompletely verified. +// @Tags Agents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param agent_id path string true "Agent ID" +// @Param body body v1.UpdateAgentRequest true "Supplied reusable Agent fields" +// @Success 200 {object} v1.SavedAgent +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/{agent_id} [post] +func (h *Handler) updateAgent(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObject(w, r) + if !ok { + return + } + input, err := resolveAgentUpdate(raw) + if err != nil { + if !writeFieldError(w, err) { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + } + return + } + id := chi.URLParam(r, "agent_id") + if !validAgentID(id) { + // Storage validation precedes the lookup; follow the missing-Agent path. + id = store.UnknownResourceID + } + updated, err := h.store.UpdateAgent(r.Context(), tenantID(r), id, input) + if err != nil { + writeStoreError(w, r, err) + return + } + h.respondAgent(w, r, updated) +} + +func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { + if err := metadataTypeError(raw); err != nil { + return store.UpdateAgentInput{}, err + } + if err := validateSavedAgentBody(raw, savedAgentUpdate); err != nil { + return store.UpdateAgentInput{}, err + } + if err := validateSavedCoreInput(raw); err != nil { + return store.UpdateAgentInput{}, err + } + var request v1.UpdateAgentRequest + if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { + return store.UpdateAgentInput{}, errors.New("Request must be a JSON object containing supported fields.") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return store.UpdateAgentInput{}, err + } + if _, supplied := fields["model"]; supplied && request.Model == nil { + return store.UpdateAgentInput{}, errors.New("model must be a string when supplied.") + } + normalized, err := resolveSavedFields(v1.CreateAgentRequest(request)) + if err != nil { + return store.UpdateAgentInput{}, err + } + var patch map[string]json.RawMessage + if err := json.Unmarshal(normalized.Configuration, &patch); err != nil { + return store.UpdateAgentInput{}, err + } + if _, supplied := fields["x_agents_core"]; supplied && request.XAgentsCore == nil { + patch["x_agents_core"] = json.RawMessage(`null`) + } + for field := range patch { + if _, supplied := fields[field]; !supplied { + delete(patch, field) + } + } + result := store.UpdateAgentInput{ModelProvider: normalized.ModelProvider} + if extension, supplied := fields["x_agents_core"]; supplied { + if request.XAgentsCore == nil { + result.ModelProviderSet = true + } else { + _, coreFields := orderedMembers(extension) + _, result.ModelProviderSet = coreFields["model_provider"] + if result.ModelProviderSet && request.XAgentsCore.ModelProvider == nil { + _, corePatch := orderedMembers(patch["x_agents_core"]) + corePatch["model_provider"] = json.RawMessage(`null`) + patch["x_agents_core"], err = json.Marshal(corePatch) + if err != nil { + return store.UpdateAgentInput{}, err + } + } + } + } + if _, supplied := fields["metadata"]; supplied { + result.Metadata = &normalized.Metadata + } + result.Configuration, err = json.Marshal(patch) + return result, err +} diff --git a/services/agents-api/internal/api/auth.go b/services/core/internal/api/auth.go similarity index 95% rename from services/agents-api/internal/api/auth.go rename to services/core/internal/api/auth.go index b12777c16..a9a30e343 100644 --- a/services/agents-api/internal/api/auth.go +++ b/services/core/internal/api/auth.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // ProjectAPIKeyResolver resolves current database credentials on each request. diff --git a/services/core/internal/api/auth_fixture_test.go b/services/core/internal/api/auth_fixture_test.go new file mode 100644 index 000000000..2178daa63 --- /dev/null +++ b/services/core/internal/api/auth_fixture_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" + "encoding/hex" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type APIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } +type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding + +func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { + if b, ok := f[digest]; ok { + return b, nil + } + return store.ProjectAPIKeyBinding{}, store.ErrNotFound +} +func NewAuthenticator(keys []APIKey) (*Authenticator, error) { + resolver := fixtureKeyResolver{} + for _, k := range keys { + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} + if err := p.Validate(); err != nil { + return nil, err + } + digest, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(digest) != 32 { + return nil, errors.New("invalid fixture digest") + } + if _, exists := resolver[k.TokenSHA256]; exists { + return nil, errors.New("duplicate fixture digest") + } + resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + } + return NewDatabaseAuthenticator(resolver) +} diff --git a/services/agents-api/internal/api/auth_test.go b/services/core/internal/api/auth_test.go similarity index 96% rename from services/agents-api/internal/api/auth_test.go rename to services/core/internal/api/auth_test.go index 1c37405ff..aacb81ffb 100644 --- a/services/agents-api/internal/api/auth_test.go +++ b/services/core/internal/api/auth_test.go @@ -5,8 +5,8 @@ import ( "net/http/httptest" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/capability_archive.go b/services/core/internal/api/capability_archive.go similarity index 85% rename from services/agents-api/internal/api/capability_archive.go rename to services/core/internal/api/capability_archive.go index 235059c53..451528ff9 100644 --- a/services/agents-api/internal/api/capability_archive.go +++ b/services/core/internal/api/capability_archive.go @@ -4,8 +4,8 @@ import ( "encoding/base64" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Skills and Plugins share the pinned inline capability source shape. diff --git a/services/agents-api/internal/api/claude_admission_test.go b/services/core/internal/api/claude_admission_test.go similarity index 100% rename from services/agents-api/internal/api/claude_admission_test.go rename to services/core/internal/api/claude_admission_test.go diff --git a/services/core/internal/api/claude_mcp_test.go b/services/core/internal/api/claude_mcp_test.go new file mode 100644 index 000000000..52214f0be --- /dev/null +++ b/services/core/internal/api/claude_mcp_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type claudeCredentialStore struct { + recordingStore + binding store.MCPCredentialBinding + calls int +} + +func (s *claudeCredentialStore) ResolveMCPCredentials(_ context.Context, _ string, _ []string, _ []store.MCPCredentialRequest) ([]store.MCPCredentialBinding, error) { + s.calls++ + return []store.MCPCredentialBinding{s.binding}, nil +} + +func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) { + for _, selection := range []string{"implicit", "explicit", "unmatched"} { + t.Run(selection, func(t *testing.T) { + vault, credential := uuid.NewString(), uuid.NewString() + s := &claudeCredentialStore{binding: store.MCPCredentialBinding{ServerLabel: "records", ServerURL: "https://mcp.example.test/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}} + tool := publicMCP + if selection == "explicit" { + tool = strings.TrimSuffix(tool, "}") + `,"credential_id":"` + credential + `"}` + } + if selection == "unmatched" { + s.binding.VaultID, s.binding.CredentialID, s.binding.AuthType = "", "", "" + } + digest := sha256.Sum256([]byte("test-api-key")) + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(digest[:]), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, auth, "claude_sdk", WithExecution(&inputRecorder{ResourceStore: s})) + if err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"agent":{"model":"model","tools":[%s]},"environment":{"type":"none"},"vault_ids":[%q],"input":"Use the configured records server."}`, tool, vault) + response := credentialRequest(h, "POST", "/v1/agents/sessions", body) + if response.Code != 201 || s.calls != 1 || s.tenant == "" { + t.Fatal("credential selection or admission failed", response.Code, response.Body, s.calls) + } + }) + } +} diff --git a/services/agents-api/internal/api/configuration.go b/services/core/internal/api/configuration.go similarity index 90% rename from services/agents-api/internal/api/configuration.go rename to services/core/internal/api/configuration.go index 3ff09d9dc..30c9a348e 100644 --- a/services/agents-api/internal/api/configuration.go +++ b/services/core/internal/api/configuration.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/configuration_validation.go b/services/core/internal/api/configuration_validation.go similarity index 100% rename from services/agents-api/internal/api/configuration_validation.go rename to services/core/internal/api/configuration_validation.go diff --git a/services/agents-api/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go similarity index 99% rename from services/agents-api/internal/api/configuration_validation_test.go rename to services/core/internal/api/configuration_validation_test.go index b76115978..b3350a453 100644 --- a/services/agents-api/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/core_errors.go b/services/core/internal/api/core_errors.go similarity index 100% rename from services/agents-api/internal/api/core_errors.go rename to services/core/internal/api/core_errors.go diff --git a/services/agents-api/internal/api/core_errors_test.go b/services/core/internal/api/core_errors_test.go similarity index 98% rename from services/agents-api/internal/api/core_errors_test.go rename to services/core/internal/api/core_errors_test.go index bff67fb1f..2cd898618 100644 --- a/services/agents-api/internal/api/core_errors_test.go +++ b/services/core/internal/api/core_errors_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/go-chi/chi/v5" ) diff --git a/services/core/internal/api/core_metrics.go b/services/core/internal/api/core_metrics.go new file mode 100644 index 000000000..01e43dda1 --- /dev/null +++ b/services/core/internal/api/core_metrics.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "net/http" + "net/url" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" +) + +type CoreMetricsService interface { + Read(context.Context, string) (coremetrics.View, error) + RecordUnavailable() +} + +func WithCoreMetrics(service CoreMetricsService) Option { + return func(h *Handler) { h.coreMetrics = service } +} + +// @Summary Retrieve Core operational metrics +// @Description Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Param range query string false "Time range (default 1h)" Enums(1h,6h,24h,7d) +// @Success 200 {object} coremetrics.View +// @Failure 400,401,503 {object} CoreErrorResponse +// @Router /core/v1/metrics [get] +func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(values) > 1 || (len(values) == 1 && len(values["range"]) != 1) { + writeError(w, http.StatusBadRequest, "invalid_request", "Only one supported range parameter is allowed.") + return + } + name := "1h" + if v, ok := values["range"]; ok { + name = v[0] + } + switch name { + case "1h", "6h", "24h", "7d": + default: + writeError(w, http.StatusBadRequest, "invalid_request", "range must be 1h, 6h, 24h or 7d.") + return + } + if h.coreMetrics == nil { + writeError(w, http.StatusServiceUnavailable, "core_metrics_unavailable", "Core metrics are not configured.") + return + } + value, err := h.coreMetrics.Read(r.Context(), name) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "core_metrics_unavailable", "Core metrics could not be read.") + return + } + writeJSON(w, http.StatusOK, value) +} + +func (h *Handler) responseHeaders(next http.Handler) http.Handler { + if h.coreMetrics == nil { + return agentsResponseHeaders(next) + } + return responseHeadersWithErrors(next, func(code string) { + if code == "execution_unavailable" { + h.coreMetrics.RecordUnavailable() + } + }) +} diff --git a/services/agents-api/internal/api/core_metrics_test.go b/services/core/internal/api/core_metrics_test.go similarity index 95% rename from services/agents-api/internal/api/core_metrics_test.go rename to services/core/internal/api/core_metrics_test.go index e0827d3d4..1685fb4a3 100644 --- a/services/agents-api/internal/api/core_metrics_test.go +++ b/services/core/internal/api/core_metrics_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" ) type metricsFixture struct { diff --git a/services/agents-api/internal/api/core_model_provider_validation_test.go b/services/core/internal/api/core_model_provider_validation_test.go similarity index 96% rename from services/agents-api/internal/api/core_model_provider_validation_test.go rename to services/core/internal/api/core_model_provider_validation_test.go index 1c62b1690..f2f8f8736 100644 --- a/services/agents-api/internal/api/core_model_provider_validation_test.go +++ b/services/core/internal/api/core_model_provider_validation_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type coreProviderValidationStore struct { diff --git a/services/agents-api/internal/api/core_routes.go b/services/core/internal/api/core_routes.go similarity index 100% rename from services/agents-api/internal/api/core_routes.go rename to services/core/internal/api/core_routes.go diff --git a/services/agents-api/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go similarity index 95% rename from services/agents-api/internal/api/core_store_validation_test.go rename to services/core/internal/api/core_store_validation_test.go index d63266419..1cf4ad1d7 100644 --- a/services/agents-api/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { diff --git a/services/agents-api/internal/api/core_validation_errors.go b/services/core/internal/api/core_validation_errors.go similarity index 92% rename from services/agents-api/internal/api/core_validation_errors.go rename to services/core/internal/api/core_validation_errors.go index acac4bad7..2b0d5e82a 100644 --- a/services/agents-api/internal/api/core_validation_errors.go +++ b/services/core/internal/api/core_validation_errors.go @@ -4,9 +4,9 @@ import ( "errors" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // writeCoreValidationError is deliberately gated by the router marker. Shared diff --git a/services/agents-api/internal/api/credentials.go b/services/core/internal/api/credentials.go similarity index 97% rename from services/agents-api/internal/api/credentials.go rename to services/core/internal/api/credentials.go index 068d9d6c4..1c0256b4f 100644 --- a/services/agents-api/internal/api/credentials.go +++ b/services/core/internal/api/credentials.go @@ -5,8 +5,8 @@ import ( "encoding/json" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/credentials_delete.go b/services/core/internal/api/credentials_delete.go similarity index 96% rename from services/agents-api/internal/api/credentials_delete.go rename to services/core/internal/api/credentials_delete.go index 1981a78bc..63ad26fd5 100644 --- a/services/agents-api/internal/api/credentials_delete.go +++ b/services/core/internal/api/credentials_delete.go @@ -4,7 +4,7 @@ import ( "bytes" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // @Summary Delete a Vault Credential diff --git a/services/agents-api/internal/api/credentials_delete_test.go b/services/core/internal/api/credentials_delete_test.go similarity index 97% rename from services/agents-api/internal/api/credentials_delete_test.go rename to services/core/internal/api/credentials_delete_test.go index b7472df0f..781cf7c73 100644 --- a/services/agents-api/internal/api/credentials_delete_test.go +++ b/services/core/internal/api/credentials_delete_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/credentials_list.go b/services/core/internal/api/credentials_list.go similarity index 97% rename from services/agents-api/internal/api/credentials_list.go rename to services/core/internal/api/credentials_list.go index 5619cc432..7736e13eb 100644 --- a/services/agents-api/internal/api/credentials_list.go +++ b/services/core/internal/api/credentials_list.go @@ -3,7 +3,7 @@ package api import ( "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // @Summary List safe Vault Credential metadata diff --git a/services/agents-api/internal/api/credentials_list_test.go b/services/core/internal/api/credentials_list_test.go similarity index 96% rename from services/agents-api/internal/api/credentials_list_test.go rename to services/core/internal/api/credentials_list_test.go index d9bcc4686..db6881c35 100644 --- a/services/agents-api/internal/api/credentials_list_test.go +++ b/services/core/internal/api/credentials_list_test.go @@ -6,8 +6,8 @@ import ( "reflect" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (f *credentialFixture) ListCredentials(_ context.Context, tenant, vault, after string, limit int, ascending bool, statuses []string) (store.CredentialPage, error) { diff --git a/services/agents-api/internal/api/credentials_oauth.go b/services/core/internal/api/credentials_oauth.go similarity index 96% rename from services/agents-api/internal/api/credentials_oauth.go rename to services/core/internal/api/credentials_oauth.go index ba7159cc4..936ea119d 100644 --- a/services/agents-api/internal/api/credentials_oauth.go +++ b/services/core/internal/api/credentials_oauth.go @@ -6,8 +6,8 @@ import ( "regexp" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func credentialHTTPSURL(value *string) bool { diff --git a/services/agents-api/internal/api/credentials_oauth_test.go b/services/core/internal/api/credentials_oauth_test.go similarity index 99% rename from services/agents-api/internal/api/credentials_oauth_test.go rename to services/core/internal/api/credentials_oauth_test.go index 3f61ee10a..2e81dcb8e 100644 --- a/services/agents-api/internal/api/credentials_oauth_test.go +++ b/services/core/internal/api/credentials_oauth_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (f *credentialFixture) CreateOAuthCredential(_ context.Context, tenant, vault string, input store.CreateOAuthCredentialInput) (store.Credential, error) { diff --git a/services/agents-api/internal/api/credentials_test.go b/services/core/internal/api/credentials_test.go similarity index 98% rename from services/agents-api/internal/api/credentials_test.go rename to services/core/internal/api/credentials_test.go index a506509b4..084d00409 100644 --- a/services/agents-api/internal/api/credentials_test.go +++ b/services/core/internal/api/credentials_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/credentials_update.go b/services/core/internal/api/credentials_update.go similarity index 95% rename from services/agents-api/internal/api/credentials_update.go rename to services/core/internal/api/credentials_update.go index 731cea456..1a62fe4ba 100644 --- a/services/agents-api/internal/api/credentials_update.go +++ b/services/core/internal/api/credentials_update.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // @Summary Replace Vault Credential authentication secrets diff --git a/services/agents-api/internal/api/credentials_update_test.go b/services/core/internal/api/credentials_update_test.go similarity index 98% rename from services/agents-api/internal/api/credentials_update_test.go rename to services/core/internal/api/credentials_update_test.go index 2af2a7317..48da83e14 100644 --- a/services/agents-api/internal/api/credentials_update_test.go +++ b/services/core/internal/api/credentials_update_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/disabled_tools.go b/services/core/internal/api/disabled_tools.go similarity index 100% rename from services/agents-api/internal/api/disabled_tools.go rename to services/core/internal/api/disabled_tools.go diff --git a/services/agents-api/internal/api/disabled_tools_test.go b/services/core/internal/api/disabled_tools_test.go similarity index 100% rename from services/agents-api/internal/api/disabled_tools_test.go rename to services/core/internal/api/disabled_tools_test.go diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go similarity index 97% rename from services/agents-api/internal/api/environment_creation_test.go rename to services/core/internal/api/environment_creation_test.go index 2425e6eff..854724063 100644 --- a/services/agents-api/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/api/environment_executor_management.go b/services/core/internal/api/environment_executor_management.go new file mode 100644 index 000000000..008b37b4c --- /dev/null +++ b/services/core/internal/api/environment_executor_management.go @@ -0,0 +1,173 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// EnvironmentExecutorStore manages the executor credentials of a Project's +// self_hosted Environments. The Project's principal is the credential's +// execution principal; the Core key that authorizes the request is not. +type EnvironmentExecutorStore interface { + ProjectExecutorCredentialState(context.Context, identity.Principal, string) (store.ExecutorCredentialState, error) + IssueProjectExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) + RevokeProjectExecutorCredential(context.Context, identity.Principal, string, string) error +} + +type EnvironmentExecutorCredentialRequest struct { + KeyID string `json:"key_id" format:"uuid"` + Rotate bool `json:"rotate,omitempty"` +} + +// ExecutorCredentialList holds credential metadata only, never a secret. +type ExecutorCredentialList struct { + Data []store.ExecutorCredential `json:"data" binding:"required"` + Connection ExecutorConnection `json:"connection" binding:"required"` +} + +// ExecutorConnection reports binding history and current Core-observed connectivity. +// Heartbeat times are observations, not execution or native readiness. +type ExecutorConnection struct { + Status string `json:"status" binding:"required" enums:"never_enrolled,connected,disconnected"` + BoundKeyID *string `json:"bound_key_id" binding:"required" extensions:"x-nullable" format:"uuid"` + EnrolledAt *time.Time `json:"enrolled_at" binding:"required" format:"date-time" extensions:"x-nullable"` + LastSeenAt *time.Time `json:"last_seen_at" binding:"required" format:"date-time" extensions:"x-nullable"` +} + +// WithExecutorConnections observes current authority and its actual gateway peer. +// The observer runs after the store snapshot closes and must recheck authority +// after inspecting the peer. Without an observer, no binding is called connected. +func WithExecutorConnections(observe func(context.Context, string, string) (bool, error)) Option { + return func(h *Handler) { h.executorConnections = observe } +} + +// registerExecutorCredentialRoutes adds executor credential issuance to the +// Core-key-authenticated /core/v1 router. +func (h *Handler) registerExecutorCredentialRoutes(r chi.Router) { + s, ok := h.store.(EnvironmentExecutorStore) + if !ok || h.projectKeys == nil { + return + } + const path = "/projects/{project_id}/environments/{environment_id}/executor-credentials" + r.Get("/projects/{project_id}/environments/{environment_id}/installation", h.getEnvironmentInstallation) + r.Get(path, func(w http.ResponseWriter, r *http.Request) { h.listExecutorCredentials(w, r, s) }) + r.Post(path, func(w http.ResponseWriter, r *http.Request) { h.issueExecutorCredential(w, r, s) }) + r.Delete(path+"/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeExecutorCredential(w, r, s) }) +} + +// @Summary List a self_hosted Environment's executor credentials +// @Description Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. Connection combines current credential authority and an open matching gateway peer; timestamps are historical observations, not readiness. Without a gateway it is never connected. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Tags Executor Credentials +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Success 200 {object} api.ExecutorCredentialList +// @Failure 401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [get] +func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + state, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + connection := ExecutorConnection{Status: "never_enrolled"} + observed := state.Connection + if observed.DeviceID != "" { + connection = ExecutorConnection{Status: "disconnected", BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt, LastSeenAt: observed.LastSeenAt} + if observed.EnvironmentStatus == "connected" && observed.CredentialHash != "" && h.executorConnections != nil { + connected, err := h.executorConnections(r.Context(), state.EnvironmentID, observed.CredentialHash) + if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, store.ErrDeviceBindingConflict) { + writeStoreError(w, r, err) + return + } + if err == nil && connected { + connection.Status = "connected" + } + } + } + writeJSON(w, http.StatusOK, ExecutorCredentialList{Data: state.Credentials, Connection: connection}) +} + +// @Summary Issue or explicitly rotate a self_hosted Environment executor credential +// @Description Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. In an archived Project, issuance and rotation return 409 project_archived. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. +// @Tags Executor Credentials +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Param body body api.EnvironmentExecutorCredentialRequest true "Request" +// @Success 201 {object} store.IssuedExecutorCredential +// @Failure 400,401,404,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [post] +func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + // Check order: request body (400), target (404), archived Project (409), + // then the key itself (409 exists, or 404 for rotating an unknown key). + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var input EnvironmentExecutorCredentialRequest + var fields map[string]json.RawMessage + if decodeInputObject(raw, &input, "key_id", "rotate") != nil || json.Unmarshal(raw, &fields) != nil || bytes.Equal(bytes.TrimSpace(fields["rotate"]), []byte("null")) || !executorManagementID(input.KeyID) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + credential, err := s.IssueProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, credential) +} + +// @Summary Revoke a self_hosted Environment executor credential +// @Description Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe and it also works in an archived Project. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Tags Executor Credentials +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Param key_id path string true "Executor key UUID" +// @Success 204 +// @Failure 401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id} [delete] +func (h *Handler) revokeExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + keyID := chi.URLParam(r, "key_id") + if !executorManagementID(keyID) { + writeStoreError(w, r, store.ErrNotFound) + return + } + if err := s.RevokeProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), keyID); err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func executorManagementID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/core/internal/api/environment_executor_management_test.go similarity index 97% rename from services/agents-api/internal/api/environment_executor_management_test.go rename to services/core/internal/api/environment_executor_management_test.go index 210e4386f..16f7bb7bd 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/core/internal/api/environment_executor_management_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/environment_files.go b/services/core/internal/api/environment_files.go similarity index 95% rename from services/agents-api/internal/api/environment_files.go rename to services/core/internal/api/environment_files.go index 2646eb8fc..f163ec73a 100644 --- a/services/agents-api/internal/api/environment_files.go +++ b/services/core/internal/api/environment_files.go @@ -9,10 +9,10 @@ import ( "strings" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/environment_files_completeness_test.go b/services/core/internal/api/environment_files_completeness_test.go similarity index 98% rename from services/agents-api/internal/api/environment_files_completeness_test.go rename to services/core/internal/api/environment_files_completeness_test.go index 8d3f63958..24db6b44d 100644 --- a/services/agents-api/internal/api/environment_files_completeness_test.go +++ b/services/core/internal/api/environment_files_completeness_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestEnvironmentFilesRejectsIncompleteOrMalformedDirectories(t *testing.T) { diff --git a/services/agents-api/internal/api/environment_files_create.go b/services/core/internal/api/environment_files_create.go similarity index 96% rename from services/agents-api/internal/api/environment_files_create.go rename to services/core/internal/api/environment_files_create.go index e8e58836e..3979f17f3 100644 --- a/services/agents-api/internal/api/environment_files_create.go +++ b/services/core/internal/api/environment_files_create.go @@ -13,11 +13,11 @@ import ( "time" "unicode/utf8" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/echotext" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/environment_files_create_test.go b/services/core/internal/api/environment_files_create_test.go similarity index 95% rename from services/agents-api/internal/api/environment_files_create_test.go rename to services/core/internal/api/environment_files_create_test.go index df856f2f5..9b0010585 100644 --- a/services/agents-api/internal/api/environment_files_create_test.go +++ b/services/core/internal/api/environment_files_create_test.go @@ -10,10 +10,10 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/environment_files_cursor.go b/services/core/internal/api/environment_files_cursor.go similarity index 97% rename from services/agents-api/internal/api/environment_files_cursor.go rename to services/core/internal/api/environment_files_cursor.go index ec25dc535..521b336cb 100644 --- a/services/agents-api/internal/api/environment_files_cursor.go +++ b/services/core/internal/api/environment_files_cursor.go @@ -8,7 +8,7 @@ import ( "encoding/json" "io" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // errEnvironmentFilePage reports every malformed, foreign or stale continuation diff --git a/services/agents-api/internal/api/environment_files_deadline_test.go b/services/core/internal/api/environment_files_deadline_test.go similarity index 95% rename from services/agents-api/internal/api/environment_files_deadline_test.go rename to services/core/internal/api/environment_files_deadline_test.go index e27d3a5eb..905e195d1 100644 --- a/services/agents-api/internal/api/environment_files_deadline_test.go +++ b/services/core/internal/api/environment_files_deadline_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" ) func TestEnvironmentFilesReadOutlivesDefaultHTTPWriteDeadline(t *testing.T) { diff --git a/services/agents-api/internal/api/environment_files_query.go b/services/core/internal/api/environment_files_query.go similarity index 97% rename from services/agents-api/internal/api/environment_files_query.go rename to services/core/internal/api/environment_files_query.go index 68a288364..4237e23e1 100644 --- a/services/agents-api/internal/api/environment_files_query.go +++ b/services/core/internal/api/environment_files_query.go @@ -7,7 +7,7 @@ import ( "strings" "unicode/utf8" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type environmentFileOptions struct { diff --git a/services/agents-api/internal/api/environment_files_test.go b/services/core/internal/api/environment_files_test.go similarity index 96% rename from services/agents-api/internal/api/environment_files_test.go rename to services/core/internal/api/environment_files_test.go index 9b067b120..8c1a75afa 100644 --- a/services/agents-api/internal/api/environment_files_test.go +++ b/services/core/internal/api/environment_files_test.go @@ -12,11 +12,11 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/environment_files_wire_test.go b/services/core/internal/api/environment_files_wire_test.go similarity index 99% rename from services/agents-api/internal/api/environment_files_wire_test.go rename to services/core/internal/api/environment_files_wire_test.go index cbdcfcee5..143304fc3 100644 --- a/services/agents-api/internal/api/environment_files_wire_test.go +++ b/services/core/internal/api/environment_files_wire_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/environment_files_write_test.go b/services/core/internal/api/environment_files_write_test.go similarity index 96% rename from services/agents-api/internal/api/environment_files_write_test.go rename to services/core/internal/api/environment_files_write_test.go index db9437177..5a3cdce6b 100644 --- a/services/agents-api/internal/api/environment_files_write_test.go +++ b/services/core/internal/api/environment_files_write_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/environment_input.go b/services/core/internal/api/environment_input.go similarity index 91% rename from services/agents-api/internal/api/environment_input.go rename to services/core/internal/api/environment_input.go index 380bec8d7..2551100cc 100644 --- a/services/agents-api/internal/api/environment_input.go +++ b/services/core/internal/api/environment_input.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" ) func (h *Handler) setEnvironmentInputWriteDeadline(w http.ResponseWriter, r *http.Request, sessionID string) error { diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/core/internal/api/environment_input_test.go similarity index 97% rename from services/agents-api/internal/api/environment_input_test.go rename to services/core/internal/api/environment_input_test.go index 2a6c26def..02d908463 100644 --- a/services/agents-api/internal/api/environment_input_test.go +++ b/services/core/internal/api/environment_input_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestPublicEnvironmentInputFailureMappings(t *testing.T) { diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go new file mode 100644 index 000000000..9c9cb9d80 --- /dev/null +++ b/services/core/internal/api/environment_installation.go @@ -0,0 +1,183 @@ +package api + +import ( + "context" + "net/http" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type environmentInstallationStore interface { + AuthorizeEnvironmentInstallation(context.Context, identity.Principal, string, string) (string, int64, error) + ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) + ClaimEnvironmentInstallation(context.Context, string, string, string) error +} + +func WithNativeInstaller(catalog *nativeinstaller.Catalog, version string) Option { + return func(h *Handler) { h.nativeInstaller, h.nativeVersion = catalog, version } +} + +func (h *Handler) installationFor(ctx context.Context, principal identity.Principal, environment string) (*v1.EnvironmentInstallation, error) { + result := &v1.EnvironmentInstallation{Status: "unavailable", Version: h.nativeVersion, Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} + s, ok := h.store.(environmentInstallationStore) + if !ok || h.nativeInstaller == nil { + return result, nil + } + token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, principal, environment, h.nativeVersion) + if err != nil { + return nil, err + } + origin := strings.TrimSuffix(h.executorURL, "/api/v1/agent-daemon/ws") + origin = strings.Replace(strings.Replace(origin, "wss://", "https://", 1), "ws://", "http://", 1) + return &v1.EnvironmentInstallation{Status: "available", Version: h.nativeVersion, ExpiresAt: expires, Commands: h.nativeInstaller.Commands(origin, token)}, nil +} + +func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { + if response.Environment.Type != "self_hosted" || h.nativeVersion == "" { + return nil + } + principal, ok := r.Context().Value(principalContextKey{}).(identity.Principal) + if !ok { + return nil + } + installation, err := h.installationFor(r.Context(), principal, response.Environment.ID) + if err != nil { + return err + } + w.Header().Set("Cache-Control", "no-store") + response.XAgentsCore = &v1.SessionCore{Installation: installation} + return nil +} + +func (h *Handler) registerNativeInstallationRoutes(r chi.Router) { + if h.nativeVersion == "" { + return + } + if h.nativeInstaller != nil { + r.Handle("/api/v1/agent-daemon/install/*", h.nativeInstaller) + } + r.Post("/api/v1/agent-daemon/installation", h.prepareNativeInstallation) + r.Post("/api/v1/agent-daemon/installation/claim", h.claimNativeInstallation) +} + +func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Request) (environmentInstallationStore, store.InstallationAuthorization, string, bool) { + w.Header().Set("Cache-Control", "no-store") + s, ok := h.store.(environmentInstallationStore) + if !ok || h.nativeInstaller == nil { + writeError(w, 503, "installation_unavailable", "Matching native installation artifacts are unavailable.") + return nil, store.InstallationAuthorization{}, "", false + } + parts := strings.Fields(r.Header.Get("Authorization")) + if len(r.Header.Values("Authorization")) != 1 || len(parts) != 2 || parts[0] != "Bearer" { + writeStoreError(w, r, store.ErrInstallationAuthorization) + return nil, store.InstallationAuthorization{}, "", false + } + claim, err := s.ValidateEnvironmentInstallation(r.Context(), parts[1], h.nativeVersion) + if err != nil { + writeStoreError(w, r, err) + return nil, claim, "", false + } + return s, claim, parts[1], true +} + +// @Summary Resolve a native installation authorization +// @Description Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. +// @Tags Native Installation +// @Produce json +// @Success 200 {object} v1.NativeInstallationContext +// @Failure 401,404,503 {object} CoreErrorResponse +// @Router /api/v1/agent-daemon/installation [post] +func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Request) { + _, claim, _, ok := h.installationAuthorization(w, r) + if !ok { + return + } + environment, err := h.store.GetEnvironment(r.Context(), claim.Principal.TenantID, claim.Environment) + if err != nil { + writeStoreError(w, r, err) + return + } + session, err := h.store.GetSession(r.Context(), claim.Principal.TenantID, environment.SessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := sessionResponse(session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.NativeInstallationContext{Version: h.nativeVersion, ProtocolVersion: proto.Version, EnvironmentID: claim.Environment, RemoteURL: h.executorURL, Workspace: response.Environment.WorkspaceDirectory, Harness: session.Engine}) +} + +type NativeInstallationClaim struct { + ExecutorToken string `json:"executor_token"` +} + +// @Summary Claim an Environment's installation credential +// @Description A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. +// @Tags Native Installation +// @Accept json +// @Param body body api.NativeInstallationClaim true "Locally persisted executor secret" +// @Success 204 +// @Failure 400,401,409,503 {object} CoreErrorResponse +// @Router /api/v1/agent-daemon/installation/claim [post] +func (h *Handler) claimNativeInstallation(w http.ResponseWriter, r *http.Request) { + s, _, token, ok := h.installationAuthorization(w, r) + if !ok { + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var input NativeInstallationClaim + if decodeInputObject(raw, &input, "executor_token") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if err := s.ClaimEnvironmentInstallation(r.Context(), token, h.nativeVersion, input.ExecutorToken); err != nil { + writeStoreError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// @Summary Get a self_hosted Session's installation commands +// @Description Core key only. The commands contain a 30-minute installation authorization, never an executor secret. Web displays these same commands provided in public Session creation and detail responses. +// @Tags Native Installation +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Success 200 {object} v1.EnvironmentInstallation +// @Failure 401,404,409 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/installation [get] +func (h *Handler) getEnvironmentInstallation(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + environment := chi.URLParam(r, "environment_id") + s, ok := h.store.(EnvironmentExecutorStore) + if !ok { + writeStoreError(w, r, store.ErrNotFound) + return + } + if _, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, environment); err != nil { + writeStoreError(w, r, err) + return + } + result, err := h.installationFor(r.Context(), binding.Principal, environment) + if err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, result) +} diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go new file mode 100644 index 000000000..f3c77d4bd --- /dev/null +++ b/services/core/internal/api/environment_installation_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type installationFixture struct { + environmentCreationFixture + authorizedEnvironment string +} + +func (f *installationFixture) AuthorizeEnvironmentInstallation(_ context.Context, p identity.Principal, environment, version string) (string, int64, error) { + if p.TenantID != f.session.TenantID || environment != f.session.Environment.ID || version != "build" { + return "", 0, store.ErrNotFound + } + f.authorizedEnvironment = environment + return "short-lived-install-grant", 2000000000, nil +} +func (f *installationFixture) ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) { + return store.InstallationAuthorization{}, store.ErrInstallationAuthorization +} +func (f *installationFixture) ClaimEnvironmentInstallation(context.Context, string, string, string) error { + return store.ErrInstallationAuthorization +} + +func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) { + f := &installationFixture{} + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential("project-key"), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandler(f, auth, "codex", withFixtureDeploymentProvider(), WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL("wss://core.example/api/v1/agent-daemon/ws"), WithNativeInstaller(&nativeinstaller.Catalog{Version: "build"}, "build")) + if err != nil { + t.Fatal(err) + } + body := `{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture-model"}}}` + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer project-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusCreated { + t.Fatal(w.Code, w.Body.String()) + } + var response v1.Session + if json.Unmarshal(w.Body.Bytes(), &response) != nil || response.XAgentsCore == nil || response.XAgentsCore.Installation == nil { + t.Fatal("installation omitted") + } + if f.authorizedEnvironment != response.Environment.ID || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("wrong authorization scope or caching") + } + for _, shell := range []string{"posix", "powershell"} { + command := response.XAgentsCore.Installation.Commands[shell] + if !strings.Contains(command, "short-lived-install-grant") || strings.Contains(command, "project-key") || strings.Contains(command, "fixture-model") { + t.Fatal("incorrect command authority") + } + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-daemon/installation", nil) + request.Header.Set("Authorization", "Bearer "+response.Environment.ID) + w = httptest.NewRecorder() + handler.ServeHTTP(w, request) + if w.Code != http.StatusUnauthorized { + t.Fatal("Environment ID authenticated installation", w.Code) + } +} diff --git a/services/agents-api/internal/api/environment_network_test.go b/services/core/internal/api/environment_network_test.go similarity index 98% rename from services/agents-api/internal/api/environment_network_test.go rename to services/core/internal/api/environment_network_test.go index 8bc63b31b..4c3da0ac9 100644 --- a/services/agents-api/internal/api/environment_network_test.go +++ b/services/core/internal/api/environment_network_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestRestrictedNetworkPublicMetadataPreservesInput(t *testing.T) { diff --git a/services/agents-api/internal/api/environment_packages_test.go b/services/core/internal/api/environment_packages_test.go similarity index 100% rename from services/agents-api/internal/api/environment_packages_test.go rename to services/core/internal/api/environment_packages_test.go diff --git a/services/core/internal/api/environment_plugins.go b/services/core/internal/api/environment_plugins.go new file mode 100644 index 000000000..c596fa7f7 --- /dev/null +++ b/services/core/internal/api/environment_plugins.go @@ -0,0 +1,67 @@ +package api + +import ( + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + result := make([]store.EnvironmentPlugin, 0, len(entries)) + for _, entry := range entries { + var input struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Source json.RawMessage `json:"source"` + } + if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { + return nil, store.ErrInvalidInput + } + body, err := decodeCapabilityArchive(input.Source) + if err != nil { + return nil, err + } + result = append(result, store.EnvironmentPlugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + } + return result, store.ValidateEnvironmentPlugins(result) +} + +func pluginResponse(plugins []agentplugin.Metadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(plugins)) + for _, plugin := range plugins { + raw, _ := json.Marshal(plugin) + result = append(result, raw) + } + return result +} + +func storedPlugins(raw json.RawMessage) ([]json.RawMessage, error) { + if len(raw) == 0 { + return []json.RawMessage{}, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + seen := map[string]bool{} + for _, entry := range entries { + var metadata agentplugin.Metadata + if decodeInputObject(entry, &metadata, "type", "name", "description") != nil || metadata.Type != "inline" || metadata.Name == "" || metadata.Description == "" || seen[metadata.Name] { + return nil, store.ErrInvalidInput + } + seen[metadata.Name] = true + } + if entries == nil { + entries = []json.RawMessage{} + } + return entries, nil +} diff --git a/services/core/internal/api/environment_plugins_test.go b/services/core/internal/api/environment_plugins_test.go new file mode 100644 index 000000000..794bfb190 --- /dev/null +++ b/services/core/internal/api/environment_plugins_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func pluginInput(t *testing.T) json.RawMessage { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{ + ".codex-plugin/plugin.json": `{"name":"proof-plugin","description":"Two Skills.","skills":["./skills"]}`, + "skills/alpha/SKILL.md": "---\nname: alpha\ndescription: Alpha proof.\n---\nprivate-plugin-canary", + "skills/beta/SKILL.md": "---\nname: beta\ndescription: Beta proof.\n---\nUse ../../shared/data.txt", + "shared/data.txt": "private-plugin-resource", + } { + f, err := writer.Create("proof/" + path) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof-plugin", "description": "Two Skills.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { + plugin := pluginInput(t) + raw := []byte(`{"plugins":[` + string(plugin) + `],"capability_directories":["/workspace/generated"]}`) + template, err := decodeTemplateInput(raw) + if err != nil || !template.SetPlugins || !template.SetDirectories || len(template.Initialization.Plugins) != 1 { + t.Fatal("template", err) + } + var decoded decodedSessionRequest + if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted",`+string(raw[1:])+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { + t.Fatal("different installation inputs") + } + cfg, err := resolve(input, "tenant", "key", nil) + if err != nil || bytes.Contains(cfg, []byte(`"source"`)) || bytes.Contains(cfg, []byte("private-plugin")) { + t.Fatal("unsafe snapshot", err) + } + var snapshot struct { + Environment json.RawMessage `json:"environment"` + } + if err = json.Unmarshal(cfg, &snapshot); err != nil { + t.Fatal(err) + } + stored, err := storedEnvironment(snapshot.Environment) + if err != nil || len(stored.Plugins) != 1 || len(stored.CapabilityDirectories) != 1 { + t.Fatal("metadata", err) + } + env := store.Environment{ID: "environment", Status: "connected", Configuration: snapshot.Environment} + if response, err := environmentResponse(env); err != nil || len(response.Plugins) != 1 { + t.Fatal("environment response", err) + } + if response, err := hostedSessionEnvironment(env); err != nil || len(*response.Plugins) != 1 || len(*response.CapabilityDirectories) != 1 { + t.Fatal("session response", err) + } + lookup := &templateLookupStore{network: "enabled", plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} + h := Handler{store: lookup} + for _, override := range []string{"", `,"plugins":null,"capability_directories":null`, `,"plugins":[],"capability_directories":[]`} { + if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+override+`}}`), &decoded); err != nil { + t.Fatal(err) + } + in, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(in, nil) + if err != nil || !bytes.Contains(intent, []byte("template")) { + t.Fatal("intent", err) + } + if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &in); err != nil { + t.Fatal(err) + } + want := 1 + if strings.Contains(override, "[]") { + want = 0 + } + if len(in.initialization.Plugins) != want || len(in.initialization.CapabilityDirectories) != want || len(in.Environment.Plugins) != want { + t.Fatal("inheritance/replacement") + } + } + for _, directory := range []string{`null`, `"/private"`, `"/workspace/../private"`, `"relative"`} { + if _, err := decodeTemplateInput([]byte(`{"capability_directories":[` + directory + `]}`)); err == nil { + t.Fatal("unsafe directory") + } + } + bad := strings.Replace(string(plugin), `"name":"proof-plugin"`, `"name":"mismatch"`, 1) + if _, err := decodeEnvironmentPlugins([]byte("[" + bad + "]")); err == nil { + t.Fatal("mismatched identity") + } + if _, err := decodeEnvironmentPlugins([]byte("[" + string(plugin) + "," + string(plugin) + "]")); err == nil { + t.Fatal("duplicate identity") + } +} diff --git a/services/agents-api/internal/api/environment_preparation_input.go b/services/core/internal/api/environment_preparation_input.go similarity index 91% rename from services/agents-api/internal/api/environment_preparation_input.go rename to services/core/internal/api/environment_preparation_input.go index 55807bae8..e20320abd 100644 --- a/services/agents-api/internal/api/environment_preparation_input.go +++ b/services/core/internal/api/environment_preparation_input.go @@ -4,8 +4,8 @@ import ( "bytes" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // The official placement union and the Core extension converge before resource diff --git a/services/agents-api/internal/api/environment_preparation_input_test.go b/services/core/internal/api/environment_preparation_input_test.go similarity index 100% rename from services/agents-api/internal/api/environment_preparation_input_test.go rename to services/core/internal/api/environment_preparation_input_test.go diff --git a/services/agents-api/internal/api/environment_request.go b/services/core/internal/api/environment_request.go similarity index 84% rename from services/agents-api/internal/api/environment_request.go rename to services/core/internal/api/environment_request.go index 245af23dc..e142e43a0 100644 --- a/services/agents-api/internal/api/environment_request.go +++ b/services/core/internal/api/environment_request.go @@ -3,9 +3,9 @@ package api import ( "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { diff --git a/services/agents-api/internal/api/environment_request_test.go b/services/core/internal/api/environment_request_test.go similarity index 100% rename from services/agents-api/internal/api/environment_request_test.go rename to services/core/internal/api/environment_request_test.go diff --git a/services/core/internal/api/environment_setup.go b/services/core/internal/api/environment_setup.go new file mode 100644 index 000000000..131658065 --- /dev/null +++ b/services/core/internal/api/environment_setup.go @@ -0,0 +1,122 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +var errSystemPackages = &fieldError{ + param: "packages.system", + message: "Runtime system package installation is not supported. Preinstall system dependencies in the sandbox image or template, or on the host machine.", +} + +func rejectSystemPackages(raw json.RawMessage) error { + var packages map[string]json.RawMessage + if json.Unmarshal(raw, &packages) == nil { + if _, supplied := packages["system"]; supplied { + return errSystemPackages + } + } + return nil +} + +func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { + var result store.EnvironmentSetup + if err := rejectSystemPackages(fields["packages"]); err != nil { + return result, err + } + if value, ok := fields["env"]; ok { + var entries map[string]*string + if json.Unmarshal(value, &entries) != nil { + return result, store.ErrInvalidInput + } + result.Env = make(map[string]string, len(entries)) + for name, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + result.Env[name] = *entry + } + } + if value, ok := fields["setup_commands"]; ok { + var commands []json.RawMessage + if json.Unmarshal(value, &commands) != nil { + return result, store.ErrInvalidInput + } + for _, command := range commands { + var input struct { + Command *string `json:"command"` + CWD *string `json:"cwd"` + } + if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil { + return result, store.ErrInvalidInput + } + step := store.SetupCommand{Command: *input.Command} + if input.CWD != nil { + if *input.CWD == "" { + return result, store.ErrInvalidInput + } + step.CWD = *input.CWD + } + result.Commands = append(result.Commands, step) + } + } + if value, ok := fields["packages"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + var input struct { + NPM []*string `json:"npm"` + Python []*string `json:"python"` + } + if decodeInputObject(value, &input, "npm", "python") != nil { + return result, store.ErrInvalidInput + } + for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python} { + var target *[]string + switch name { + case "npm": + target = &result.Packages.NPM + case "python": + target = &result.Packages.Python + } + for _, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + *target = append(*target, *entry) + } + } + } + var err error + result.Skills, err = decodeEnvironmentSkills(fields["skills"]) + if err != nil { + return result, err + } + result.Plugins, err = decodeEnvironmentPlugins(fields["plugins"]) + if err != nil { + return result, err + } + if raw, supplied := fields["capability_directories"]; supplied { + var entries []*string + if json.Unmarshal(raw, &entries) != nil { + return result, store.ErrInvalidInput + } + for _, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + result.CapabilityDirectories = append(result.CapabilityDirectories, *entry) + } + } + return result, result.Validate() +} + +func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackagesResponse { + value := store.EnvironmentSetup{} + if packages != nil { + value.Packages = *packages + } + normalized := value.PackageMetadata() + return v1.EnvironmentPackagesResponse{NPM: normalized.NPM, Python: normalized.Python, System: []string{}} +} diff --git a/services/agents-api/internal/api/environment_setup_test.go b/services/core/internal/api/environment_setup_test.go similarity index 100% rename from services/agents-api/internal/api/environment_setup_test.go rename to services/core/internal/api/environment_setup_test.go diff --git a/services/agents-api/internal/api/environment_skill_selectors_test.go b/services/core/internal/api/environment_skill_selectors_test.go similarity index 98% rename from services/agents-api/internal/api/environment_skill_selectors_test.go rename to services/core/internal/api/environment_skill_selectors_test.go index eed2d05d0..b21e4d1c6 100644 --- a/services/agents-api/internal/api/environment_skill_selectors_test.go +++ b/services/core/internal/api/environment_skill_selectors_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSkillReferenceNullableSelectorAdmissionAndTemplateProjection(t *testing.T) { diff --git a/services/core/internal/api/environment_skills.go b/services/core/internal/api/environment_skills.go new file mode 100644 index 000000000..f64fc8930 --- /dev/null +++ b/services/core/internal/api/environment_skills.go @@ -0,0 +1,98 @@ +package api + +import ( + "bytes" + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + result := make([]store.EnvironmentSkill, 0, len(entries)) + for _, entry := range entries { + var discriminator struct { + Type string `json:"type"` + } + if json.Unmarshal(entry, &discriminator) != nil { + return nil, store.ErrInvalidInput + } + if discriminator.Type == "skill_reference" { + var reference struct { + Type string `json:"type"` + SkillID string `json:"skill_id"` + Version json.RawMessage `json:"version"` + } + if decodeInputObject(entry, &reference, "type", "skill_id", "version") != nil { + return nil, store.ErrInvalidInput + } + metadata := store.EnvironmentSkillMetadata{Type: reference.Type, SkillID: reference.SkillID} + if len(reference.Version) > 0 && !bytes.Equal(bytes.TrimSpace(reference.Version), []byte("null")) { + if json.Unmarshal(reference.Version, &metadata.Version) != nil || metadata.Version == "" { + return nil, store.ErrInvalidInput + } + } + result = append(result, store.EnvironmentSkill{Metadata: metadata}) + continue + } + var input struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Source json.RawMessage `json:"source"` + } + if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { + return nil, store.ErrInvalidInput + } + body, err := decodeCapabilityArchive(input.Source) + if err != nil { + return nil, err + } + result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + } + return result, store.ValidateEnvironmentSkills(result) +} + +func skillResponse(skills []store.EnvironmentSkillMetadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(skills)) + for _, skill := range skills { + var projection any = skill + if skill.Type == "skill_reference" && skill.Version == "" { + projection = struct { + store.EnvironmentSkillMetadata + Version *string `json:"version"` + }{EnvironmentSkillMetadata: skill} + } + raw, _ := json.Marshal(projection) + result = append(result, raw) + } + return result +} + +func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { + if len(raw) == 0 { + return []json.RawMessage{}, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + seen := map[string]bool{} + for _, entry := range entries { + var metadata store.EnvironmentSkillMetadata + if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || store.ValidateInstalledSkillMetadata(metadata) != nil || seen[metadata.Name] { + return nil, store.ErrInvalidInput + } + seen[metadata.Name] = true + } + if entries == nil { + entries = []json.RawMessage{} + } + return entries, nil +} diff --git a/services/core/internal/api/environment_skills_test.go b/services/core/internal/api/environment_skills_test.go new file mode 100644 index 000000000..7d5aba6d5 --- /dev/null +++ b/services/core/internal/api/environment_skills_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func skillInput(t *testing.T, body string) json.RawMessage { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.Create("proof/SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\n" + body)); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof", "description": "A proof.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { + lookup := &templateLookupStore{network: "enabled", skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} + h := Handler{store: lookup} + for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} { + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+fields+`}}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || len(intent) == 0 { + t.Fatal("missing unresolved retry intent", err) + } + if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + t.Fatal(err) + } + switch fields { + case "": + if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata != lookup.skills[0].Metadata { + t.Fatal("reference inheritance") + } + case `,"skills":[]`: + if len(input.initialization.Skills) != 0 { + t.Fatal("explicit empty list did not replace") + } + default: + if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata.SkillID != "skill-override" || input.initialization.Skills[0].Metadata.Version != "2" { + t.Fatal("reference replacement") + } + } + } + for _, version := range []string{"", `,"version":"latest"`, `,"version":"1"`} { + raw := []byte(`{"type":"openai_hosted","skills":[{"type":"skill_reference","skill_id":"skill-owned"` + version + `}]}`) + var decoded decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{"agent":{"model":"test"},"environment":`), raw...), '}'), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || !bytes.Contains(intent, []byte("skill-owned")) { + t.Fatal("inline reference lost retry intent", err) + } + } + for _, version := range []string{`1`, `""`, `"0"`} { + if _, err := decodeEnvironmentSkills([]byte(`[{"type":"skill_reference","skill_id":"skill-owned","version":` + version + `}]`)); err == nil { + t.Fatal("invalid or unconfirmed selector accepted") + } + } +} + +func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { + skill := skillInput(t, "private-skill-canary") + raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) + template, err := decodeTemplateInput(raw) + if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { + t.Fatal("template", err) + } + environment := append([]byte(`{"type":"openai_hosted",`), raw[1:]...) + decode := func(agent string, environment []byte) sessionRequest { + var request decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{`+agent+`,"environment":`), environment...), '}'), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + return input + } + inline := decode(`"agent":{"model":"test"}`, environment) + if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Initialization.Skills[0].Archive) { + t.Fatal("inline/template differ") + } + configuration, err := resolve(inline, "tenant", "key", nil) + if err != nil || bytes.Contains(configuration, []byte(`"source"`)) || bytes.Contains(configuration, []byte(`"archive"`)) { + t.Fatal("confidential snapshot", err) + } + public, err := storedEnvironment(mustEnvironment(t, configuration)) + if err != nil || len(public.Skills) != 1 || !bytes.Contains(public.Skills[0], []byte(`"name":"proof"`)) { + t.Fatal("metadata", err) + } + intent, err := sessionCreationRequest(decode(`"agent_id":"saved"`, environment), nil) + if err != nil { + t.Fatal(err) + } + changed := append(append([]byte(`{"type":"openai_hosted","skills":[`), skillInput(t, "different")...), []byte(`]}`)...) + other, err := sessionCreationRequest(decode(`"agent_id":"saved"`, changed), nil) + if err != nil || bytes.Equal(intent, other) { + t.Fatal("archive omitted from retry identity", err) + } + for _, clearing := range []string{`{"skills":null}`, `{"skills":[]}`} { + input, err := decodeTemplateInput([]byte(clearing)) + if err != nil || !input.SetSkills || !input.Initialization.Empty() { + t.Fatal("clear", err) + } + } + for _, invalid := range []string{`{"skills":[null]}`, `{"skills":[{"type":"skill_reference","skill_id":""}]}`, `{"skills":[{"type":"inline","name":"proof","description":"A proof.","source":{"type":"base64","media_type":"application/zip","data":"invalid"}}]}`} { + if _, err := decodeTemplateInput([]byte(invalid)); err == nil { + t.Fatal("invalid or unsupported skill accepted") + } + } + duplicate := append(append(append(append([]byte(`{"skills":[`), skill...), ','), skill...), []byte(`]}`)...) + if _, err := decodeTemplateInput(duplicate); err == nil { + t.Fatal("duplicate skill accepted") + } +} + +func mustEnvironment(t *testing.T, configuration []byte) json.RawMessage { + t.Helper() + var fields map[string]json.RawMessage + if err := json.Unmarshal(configuration, &fields); err != nil { + t.Fatal(err) + } + return fields["environment"] +} diff --git a/services/core/internal/api/environment_templates.go b/services/core/internal/api/environment_templates.go new file mode 100644 index 000000000..3dbfc3005 --- /dev/null +++ b/services/core/internal/api/environment_templates.go @@ -0,0 +1,206 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type EnvironmentTemplateStore interface { + ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error) + CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error) + UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + DeleteEnvironmentTemplate(context.Context, string, string) (string, error) + ListEnvironmentTemplates(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) +} + +func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { + var fields map[string]json.RawMessage + if decodeInputObject(raw, &fields, "name", "network", "capability_directories", "env", "files", "packages", "plugins", "skills", "setup_commands") != nil { + return store.EnvironmentTemplateInput{}, store.ErrInvalidInput + } + in := store.EnvironmentTemplateInput{} + if value, supplied := fields["name"]; supplied { + in.SetName = true + if json.Unmarshal(value, &in.Name) != nil || (in.Name != nil && (!utf8.ValidString(*in.Name) || utf8.RuneCountInString(*in.Name) < 1 || utf8.RuneCountInString(*in.Name) > 256)) { + return in, store.ErrInvalidInput + } + } + delete(fields, "name") + _, in.SetNetwork = fields["network"] + _, in.SetFiles = fields["files"] + _, in.SetEnv = fields["env"] + _, in.SetSetup = fields["setup_commands"] + _, in.SetPackages = fields["packages"] + _, in.SetSkills = fields["skills"] + _, in.SetPlugins = fields["plugins"] + _, in.SetDirectories = fields["capability_directories"] + var setupErr error + in.Initialization, setupErr = decodeEnvironmentSetup(fields) + if setupErr != nil { + return in, setupErr + } + var fileErr error + in.Files, fileErr = decodeInitialFiles(fields["files"]) + if fileErr != nil { + return in, fileErr + } + fields["type"] = json.RawMessage(`"openai_hosted"`) + configuration, err := json.Marshal(fields) + if err != nil { + return in, err + } + environment, err := decodeHostedEnvironment(configuration) + if err != nil { + return in, err + } + in.NetworkAccess = environment.Network.Access + in.AllowedDomains = append([]string{}, environment.Network.AllowedDomains...) + return in, nil +} + +func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: append([]string{}, t.CapabilityDirectories...), Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: pluginResponse(t.Plugins), Skills: skillResponse(t.Skills)} +} + +func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.EnvironmentTemplateInput, bool) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return store.EnvironmentTemplateInput{}, false + } + in, err := decodeTemplateInput(raw) + if writeFieldError(w, err) { + return in, false + } + if err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") + return in, false + } + return in, true +} + +// @Summary Create an Environment Template +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.EnvironmentTemplateRequest true "Reusable configuration" +// @Success 201 {object} v1.EnvironmentTemplate +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [post] +func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.CreateEnvironmentTemplate(r.Context(), tenantID(r), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, templateResponse(value)) +} + +// @Summary Retrieve an Environment Template +// @Description Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found response. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [get] +func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + value, err := h.store.GetEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Update an Environment Template +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport and runtime network policy. Empty updates advance updated_at without changing saved fields or confidential contents. Network policy rejections return invalid_request_error with a null param. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Param body body v1.EnvironmentTemplateRequest true "Configuration replacements" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [post] +func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.UpdateEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id"), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Delete an Environment Template +// @Description Deletes the tenant-owned reusable configuration without changing or deleting existing Sessions and their frozen configuration. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplateDeleted +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [delete] +func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + id, err := h.store.DeleteEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.EnvironmentTemplateDeleted{ID: id, Object: "agent.environment.template.deleted", Deleted: true}) +} + +// @Summary List Environment Templates +// @Description Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit 0 is treated as 1 and larger limits as 100. Foreign, missing and malformed cursors return the same not found error. Concurrent-page and exact hosted error behavior remain unverified. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Previous Template ID" +// @Param limit query integer false "Page size; 0 is treated as 1 and values above 100 as 100" default(20) minimum(0) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.EnvironmentTemplateList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [get] +func (h *Handler) listEnvironmentTemplates(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r) + if !ok { + return + } + page, err := h.store.ListEnvironmentTemplates(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.EnvironmentTemplateList{Object: "list", Data: make([]v1.EnvironmentTemplate, 0, len(page.Templates)), HasMore: page.HasMore} + for _, value := range page.Templates { + response.Data = append(response.Data, templateResponse(value)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/core/internal/api/environment_templates_test.go similarity index 98% rename from services/agents-api/internal/api/environment_templates_test.go rename to services/core/internal/api/environment_templates_test.go index 5ad12449f..b905320fe 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/core/internal/api/environment_templates_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { diff --git a/services/core/internal/api/environments.go b/services/core/internal/api/environments.go new file mode 100644 index 000000000..303cde3fd --- /dev/null +++ b/services/core/internal/api/environments.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Retrieve an execution Environment +// @Description Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose frozen safe metadata without content; Plugin/Skill entries expose only safe configured installation metadata. Capability-directory discoveries are not added to those arrays. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status does not prove native readiness or process quiescence. +// @Tags Environments +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_id path string true "Environment ID" +// @Success 200 {object} v1.EnvironmentInfo +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/{environment_id} [get] +func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { + environment, err := h.store.GetEnvironment(r.Context(), tenantID(r), chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := environmentResponse(environment) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, error) { + configuration, err := storedEnvironment(environment.Configuration) + if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || environment.ID == "" { + return v1.EnvironmentInfo{}, errors.New("unsupported stored environment metadata configuration") + } + switch environment.Status { + case "pending", "connected", "disconnected", "expired", "failed": + default: + return v1.EnvironmentInfo{}, errors.New("unsupported stored environment resource status") + } + files := configuration.Files + if files == nil { + files = []json.RawMessage{} + } + if configuration.Skills == nil { + configuration.Skills = []json.RawMessage{} + } + if configuration.Plugins == nil { + configuration.Plugins = []json.RawMessage{} + } + return v1.EnvironmentInfo{ + ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, + Files: files, Plugins: configuration.Plugins, Skills: configuration.Skills, + }, nil +} diff --git a/services/agents-api/internal/api/environments_test.go b/services/core/internal/api/environments_test.go similarity index 98% rename from services/agents-api/internal/api/environments_test.go rename to services/core/internal/api/environments_test.go index 2639c0307..eb6d1de36 100644 --- a/services/agents-api/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/errors.go b/services/core/internal/api/errors.go similarity index 97% rename from services/agents-api/internal/api/errors.go rename to services/core/internal/api/errors.go index a2530596e..4407624c5 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -6,12 +6,12 @@ import ( "net/http" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func writeJSON(w http.ResponseWriter, status int, value any) { diff --git a/services/agents-api/internal/api/errors_test.go b/services/core/internal/api/errors_test.go similarity index 97% rename from services/agents-api/internal/api/errors_test.go rename to services/core/internal/api/errors_test.go index 21a243b49..15abd3aff 100644 --- a/services/agents-api/internal/api/errors_test.go +++ b/services/core/internal/api/errors_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestResourceNotFoundErrorSurfaces(t *testing.T) { diff --git a/services/agents-api/internal/api/execution_policy.go b/services/core/internal/api/execution_policy.go similarity index 77% rename from services/agents-api/internal/api/execution_policy.go rename to services/core/internal/api/execution_policy.go index b5570c8c9..b7c5ec472 100644 --- a/services/agents-api/internal/api/execution_policy.go +++ b/services/core/internal/api/execution_policy.go @@ -1,6 +1,6 @@ package api -import "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" +import "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" // WithExecutionPolicy supplies the same immutable qualification used by the // execution Dispatcher. Omission uses the built-in engine registrations. diff --git a/services/agents-api/internal/api/function_configuration.go b/services/core/internal/api/function_configuration.go similarity index 97% rename from services/agents-api/internal/api/function_configuration.go rename to services/core/internal/api/function_configuration.go index 3872bf551..6d013317c 100644 --- a/services/agents-api/internal/api/function_configuration.go +++ b/services/core/internal/api/function_configuration.go @@ -6,7 +6,7 @@ import ( "errors" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func resolveFunctions(input []v1.FunctionToolInput) ([]json.RawMessage, error) { diff --git a/services/agents-api/internal/api/function_configuration_test.go b/services/core/internal/api/function_configuration_test.go similarity index 97% rename from services/agents-api/internal/api/function_configuration_test.go rename to services/core/internal/api/function_configuration_test.go index c996a8856..2f55ecb6a 100644 --- a/services/agents-api/internal/api/function_configuration_test.go +++ b/services/core/internal/api/function_configuration_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func TestPublicFunctionConfiguration(t *testing.T) { diff --git a/services/core/internal/api/function_inputs.go b/services/core/internal/api/function_inputs.go new file mode 100644 index 000000000..aabe1ee9d --- /dev/null +++ b/services/core/internal/api/function_inputs.go @@ -0,0 +1,148 @@ +package api + +import ( + "bytes" + "encoding/json" + "reflect" + "slices" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type decodedInputEvent struct { + v1.SessionInput + Output json.RawMessage `json:"output,omitempty"` +} + +func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { + var event decodedInputEvent + if err := json.Unmarshal(raw, &event); err != nil { + return event, store.ErrInvalidInput + } + fields := []string{"type"} + switch event.Type { + case "agent.session.input.message": + fields = append(fields, "input") + var messages struct { + Input []struct { + Type json.RawMessage `json:"type"` + Content json.RawMessage `json:"content"` + } `json:"input"` + } + if json.Unmarshal(raw, &messages) != nil { + return event, store.ErrInvalidInput + } + for _, message := range messages.Input { + if len(message.Type) > 0 { + var kind string + if json.Unmarshal(message.Type, &kind) != nil || kind != "message" { + return event, store.ErrInvalidInput + } + } + if err := validateInputContent(message.Content); err != nil { + return event, err + } + } + case "agent.session.input.cancel": + case "agent.session.input.tool_result": + fields = append(fields, "call_id", "turn_id", "success", "error", "output") + default: + return event, store.ErrInvalidInput + } + return event, decodeInputObject(raw, &event, fields...) +} + +func decodeInputObject(raw json.RawMessage, value any, allowed ...string) error { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return store.ErrInvalidInput + } + for field := range fields { + if !slices.Contains(allowed, field) { + return store.ErrInvalidInput + } + } + // Nested members match exactly too; see inexactMember. The raw value is + // valid JSON here, as Unmarshal accepted it. + if inexactMember(raw, reflect.TypeOf(value)) { + return store.ErrInvalidInput + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil { + return store.ErrInvalidInput + } + return nil +} + +func functionResultInput(event decodedInputEvent) (store.Input, error) { + if event.CallID == "" || event.TurnID == "" || event.Success == nil { + return store.Input{}, store.ErrInvalidInput + } + if len(event.Error) > 0 && !bytes.Equal(bytes.TrimSpace(event.Error), []byte("null")) { + var message string + if json.Unmarshal(event.Error, &message) != nil { + return store.Input{}, store.ErrInvalidInput + } + } + if err := validateFunctionOutput(event.Output); err != nil { + return store.Input{}, err + } + result, err := json.Marshal(struct { + Success bool `json:"success"` + Error json.RawMessage `json:"error,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + }{*event.Success, event.Error, event.Output}) + if err != nil { + return store.Input{}, err + } + payload, err := json.Marshal(store.FunctionResultInput{TurnID: event.TurnID, CallID: event.CallID, Result: result}) + return store.Input{Kind: "tool_result", Payload: payload}, err +} + +func validateFunctionOutput(raw json.RawMessage) error { + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil + } + var text string + if json.Unmarshal(raw, &text) == nil { + return nil + } + return validateInputContent(raw) +} + +func validateInputContent(raw json.RawMessage) error { + var parts []json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return store.ErrInvalidInput + } + for _, part := range parts { + var value struct { + Type string `json:"type"` + Text *string `json:"text"` + ImageURL *string `json:"image_url"` + } + if json.Unmarshal(part, &value) != nil { + return store.ErrInvalidInput + } + field := "text" + switch value.Type { + case "input_text": + if value.Text == nil { + return store.ErrInvalidInput + } + case "input_image": + field = "image_url" + if value.ImageURL == nil { + return store.ErrInvalidInput + } + default: + return store.ErrInvalidInput + } + if err := decodeInputObject(part, &value, "type", field); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/api/function_inputs_test.go b/services/core/internal/api/function_inputs_test.go similarity index 97% rename from services/agents-api/internal/api/function_inputs_test.go rename to services/core/internal/api/function_inputs_test.go index 5fb1414ed..cba1cf796 100644 --- a/services/agents-api/internal/api/function_inputs_test.go +++ b/services/core/internal/api/function_inputs_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func submitResultRequest(t *testing.T, body string, failure error) (*httptest.ResponseRecorder, *inputRecorder) { diff --git a/services/agents-api/internal/api/function_state_test.go b/services/core/internal/api/function_state_test.go similarity index 93% rename from services/agents-api/internal/api/function_state_test.go rename to services/core/internal/api/function_state_test.go index 90c391efa..d636bc618 100644 --- a/services/agents-api/internal/api/function_state_test.go +++ b/services/core/internal/api/function_state_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestFunctionStateEventsUseTheirOwnSnapshot(t *testing.T) { diff --git a/services/agents-api/internal/api/handler.go b/services/core/internal/api/handler.go similarity index 98% rename from services/agents-api/internal/api/handler.go rename to services/core/internal/api/handler.go index 13edad72c..07fd9125e 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -9,12 +9,12 @@ import ( "net/http" "reflect" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" diff --git a/services/agents-api/internal/api/handler_test.go b/services/core/internal/api/handler_test.go similarity index 97% rename from services/agents-api/internal/api/handler_test.go rename to services/core/internal/api/handler_test.go index 25ea08800..0bfbe52c8 100644 --- a/services/agents-api/internal/api/handler_test.go +++ b/services/core/internal/api/handler_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/harness.go b/services/core/internal/api/harness.go similarity index 100% rename from services/agents-api/internal/api/harness.go rename to services/core/internal/api/harness.go diff --git a/services/agents-api/internal/api/harness_model_providers.go b/services/core/internal/api/harness_model_providers.go similarity index 97% rename from services/agents-api/internal/api/harness_model_providers.go rename to services/core/internal/api/harness_model_providers.go index 51c132287..2e87e65ab 100644 --- a/services/agents-api/internal/api/harness_model_providers.go +++ b/services/core/internal/api/harness_model_providers.go @@ -6,10 +6,10 @@ import ( "slices" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/harness_test.go b/services/core/internal/api/harness_test.go similarity index 97% rename from services/agents-api/internal/api/harness_test.go rename to services/core/internal/api/harness_test.go index 8f182b3eb..5a9fac422 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/core/internal/api/harness_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSessionHarnessAdmission(t *testing.T) { diff --git a/services/agents-api/internal/api/history_pages.go b/services/core/internal/api/history_pages.go similarity index 95% rename from services/agents-api/internal/api/history_pages.go rename to services/core/internal/api/history_pages.go index b4149ce40..b28347f08 100644 --- a/services/agents-api/internal/api/history_pages.go +++ b/services/core/internal/api/history_pages.go @@ -1,6 +1,6 @@ package api -import v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +import v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" func listBounds[T any](data []T, id func(T) string) (*string, *string) { if len(data) == 0 { diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/core/internal/api/hosted_environment.go similarity index 95% rename from services/agents-api/internal/api/hosted_environment.go rename to services/core/internal/api/hosted_environment.go index 40fce6fc5..844143049 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/core/internal/api/hosted_environment.go @@ -4,10 +4,10 @@ import ( "bytes" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // errNetworkPolicy reports a network policy outside the qualified forms. The diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/core/internal/api/hosted_environment_test.go similarity index 98% rename from services/agents-api/internal/api/hosted_environment_test.go rename to services/core/internal/api/hosted_environment_test.go index 4100d4eba..a275ec14a 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/core/internal/api/hosted_environment_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { diff --git a/services/agents-api/internal/api/hosted_failure_test.go b/services/core/internal/api/hosted_failure_test.go similarity index 98% rename from services/agents-api/internal/api/hosted_failure_test.go rename to services/core/internal/api/hosted_failure_test.go index 7a0f02674..4549b1c84 100644 --- a/services/agents-api/internal/api/hosted_failure_test.go +++ b/services/core/internal/api/hosted_failure_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/hosted_structured_test.go b/services/core/internal/api/hosted_structured_test.go similarity index 96% rename from services/agents-api/internal/api/hosted_structured_test.go rename to services/core/internal/api/hosted_structured_test.go index 44bb115c7..c387aa3f7 100644 --- a/services/agents-api/internal/api/hosted_structured_test.go +++ b/services/core/internal/api/hosted_structured_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" ) func TestHostedStructuredConfigurationQualification(t *testing.T) { diff --git a/services/core/internal/api/initial_files.go b/services/core/internal/api/initial_files.go new file mode 100644 index 000000000..3efab5cb7 --- /dev/null +++ b/services/core/internal/api/initial_files.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func decodeInitialFiles(raw json.RawMessage) ([]store.InitialFile, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + files := make([]store.InitialFile, 0, len(entries)) + for _, entry := range entries { + var in struct { + Type string `json:"type"` + Path string `json:"path"` + Data *string `json:"data"` + FileID *string `json:"file_id"` + } + if decodeInputObject(entry, &in, "type", "path", "data", "file_id") != nil { + return nil, store.ErrInvalidInput + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(entry, &fields) + f := store.InitialFile{Type: in.Type, Path: in.Path} + switch in.Type { + case "inline": + if _, exists := fields["file_id"]; exists || in.Data == nil || len(*in.Data) > base64.StdEncoding.EncodedLen(5<<20) { + return nil, store.ErrInvalidInput + } + var err error + f.Data, err = base64.StdEncoding.Strict().DecodeString(*in.Data) + if err != nil { + return nil, store.ErrInvalidInput + } + case "file_id": + if _, exists := fields["data"]; exists || in.FileID == nil { + return nil, store.ErrInvalidInput + } + f.FileID = *in.FileID + default: + return nil, store.ErrInvalidInput + } + files = append(files, f) + } + return files, store.ValidateInitialFiles(files) +} + +func initialFileResponse(files []store.InitialFile) []json.RawMessage { + metadata := make([]store.InitialFileMetadata, 0, len(files)) + for _, file := range files { + m := store.InitialFileMetadata{Type: file.Type, Path: file.Path, FileID: file.FileID} + if file.Type == "inline" { + size := int64(len(file.Data)) + m.SizeBytes = &size + } + metadata = append(metadata, m) + } + return templateFileResponse(metadata) +} + +func templateFileResponse(files []store.InitialFileMetadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(files)) + for _, file := range files { + body, _ := json.Marshal(file) + result = append(result, body) + } + return result +} diff --git a/services/agents-api/internal/api/initial_files_test.go b/services/core/internal/api/initial_files_test.go similarity index 100% rename from services/agents-api/internal/api/initial_files_test.go rename to services/core/internal/api/initial_files_test.go diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go new file mode 100644 index 000000000..3892dd705 --- /dev/null +++ b/services/core/internal/api/inputs.go @@ -0,0 +1,146 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "reflect" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type InputSubmitter interface { + CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) + SubmitInputs(context.Context, string, string, string, []store.Input) ([]store.InputReceipt, error) +} + +type Option func(*Handler) + +// WithExecution enables durable admission when the service owns an execution worker. +func WithExecution(s InputSubmitter) Option { return func(h *Handler) { h.inputs = s } } + +// @Summary Submit Session input events +// @Description An empty events array is a resource-authorized no-op; it creates no execution retry identity, Turn, Item or input receipt. For environment none, atomically accepts text messages, cancellation and function results. Messages steer active work or start a queued Turn. Qualified Codex and Claude SDK workspace profiles accept text and inline PNG/JPEG messages, independently of managed or self_hosted ownership. Under the Session lock, matching retries retain their original target; new active messages append to the current Turn, while idle messages reserve work and wait up to the original five-minute connection/admission deadline. Return 202 only after durable admission, without claiming native application; active messages create no Turn or reservation. Cancellation-only prepared-environment batches use existing durable cancellation admission and return 202 without waiting for native exit; a new cancellation conflicts while a pre-Turn reservation is pending. Homogeneous tool_result-only prepared-environment batches reuse existing scoped result admission and application receipts without creating a Turn or bypassing a pending reservation. Mixed prepared-environment batches remain unsupported. HTTP expiry/cancellation use local 409 environment_input_expired/environment_input_cancelled errors. New input on a Session whose hosted Environment failed to provision returns the observed 409 conflict_error "the hosted environment failed to provision"; input already waiting when it fails and expired Environments keep the local 409 environment_unavailable. Input the Session cannot accept in its current state, such as a result after cancellation or a batch while earlier input is pending, and a result that differs from the call's saved result return 409 with type and code conflict_error; reusing an Idempotency-Key with a different batch returns the local 409 idempotency_conflict. Inside an owned Session, a result for an unknown call or for a call of another Turn returns 400 invalid_request_error and changes nothing; missing and foreign Sessions return 404. Losing execution ownership returns 503. The response write deadline accommodates the admission window for either prepared Environment, independently of new-hosted-admission and executor URL settings. Disconnecting the waiting HTTP request does not cancel retained work or restart its deadline. Retry keys identify the whole ordered batch. Function output accepts text or ordered text/image parts subject to engine support; Claude SDK accepts text results and, on none and qualified workspace profiles, successful inline PNG/JPEG results, preserving ordered content; error images and remote references reject before admission. Native image resizing may change bytes. Runtime image-result support is checked only for image-bearing delivery. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles accept ordered inline PNG/JPEG image messages. Other engines remain text-only; remote image URLs are unsupported. Image references are retained unchanged without service-side downloads. +// @Tags Sessions +// @Accept json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param Idempotency-Key header string false "Retry key, up to 128 bytes" +// @Param session_id path string true "Session ID" +// @Param body body v1.CreateEventsRequest true "Ordered input events" +// @Success 202 +// @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/events [post] +func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObject(w, r) + if !ok { + return + } + var request struct { + Events []json.RawMessage `json:"events"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + // An unknown member, including a case variant of events, is rejected before + // decoding; see inexactMember. + if inexactMember(raw, reflect.TypeOf(request)) || decoder.Decode(&request) != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Invalid Session input event request.") + return + } + key := r.Header.Get("Idempotency-Key") + if key == "" { + key = uuid.NewString() + } + if err := store.ValidateInputKey(key); err != nil { + writeStoreError(w, r, err) + return + } + if request.Events != nil && len(request.Events) == 0 { + // Empty batches have no execution identity to reserve or replay. + // Authorize the resource even when no executor is configured. + if _, err := h.store.GetSession(r.Context(), tenantID(r), chi.URLParam(r, "session_id")); err != nil { + writeStoreError(w, r, err) + return + } + if !h.auditSessionOperation(w, r, chi.URLParam(r, "session_id"), "send_events") { + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusAccepted) + return + } + if h.inputs == nil { + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not enabled on this service.") + return + } + inputs, err := executionInputs(request.Events) + if err != nil { + writeStoreError(w, r, err) + return + } + sessionID := chi.URLParam(r, "session_id") + if err := h.setEnvironmentInputWriteDeadline(w, r, sessionID); err != nil { + writeStoreError(w, r, err) + return + } + if _, err := h.inputs.SubmitInputs(r.Context(), tenantID(r), sessionID, key, inputs); err != nil { + writeInputError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusAccepted) +} + +func executionInputs(events []json.RawMessage) ([]store.Input, error) { + if len(events) == 0 || len(events) > 64 { + return nil, store.ErrInvalidInput + } + inputs := make([]store.Input, 0, len(events)) + for _, raw := range events { + event, err := decodeInputEvent(raw) + if err != nil { + return nil, err + } + switch event.Type { + case "agent.session.input.cancel": + if event.Input != nil { + return nil, store.ErrInvalidInput + } + inputs = append(inputs, store.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)}) + case "agent.session.input.tool_result": + input, err := functionResultInput(event) + if err != nil { + return nil, err + } + inputs = append(inputs, input) + case "agent.session.input.message": + if len(event.Input) == 0 { + return nil, store.ErrInvalidInput + } + for _, message := range event.Input { + if message.Role != "user" || (message.Type != "" && message.Type != "message") || len(message.Content) == 0 { + return nil, store.ErrInvalidInput + } + converted := proto.MessageInput{{}} + for _, content := range message.Content { + converted[0].Content = append(converted[0].Content, proto.InputContent{Type: content.Type, Text: content.Text, ImageURL: content.ImageURL}) + } + if converted.Validate() != nil || converted.ValidateInlineImages() != nil { + return nil, store.ErrInvalidInput + } + } + payload, err := json.Marshal(event) + if err != nil { + return nil, err + } + inputs = append(inputs, store.Input{Kind: "message", Payload: payload}) + default: + return nil, store.ErrInvalidInput + } + } + return inputs, nil +} diff --git a/services/agents-api/internal/api/inputs_test.go b/services/core/internal/api/inputs_test.go similarity index 98% rename from services/agents-api/internal/api/inputs_test.go rename to services/core/internal/api/inputs_test.go index 64b807d13..7b89a1f82 100644 --- a/services/agents-api/internal/api/inputs_test.go +++ b/services/core/internal/api/inputs_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type inputRecorder struct { diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go new file mode 100644 index 000000000..58f09a0f8 --- /dev/null +++ b/services/core/internal/api/installation.go @@ -0,0 +1,126 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "path/filepath" + "regexp" + "slices" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// Installation reports Core's installation facts. Core reads them from its +// environment and build; configuration is the installer's settings snapshot. +type Installation struct { + Object string `json:"object" enums:"core.installation"` + // OAC_INSTALLATION_ID; null when Core runs without the sandbox manager. + InstallationID *string `json:"installation_id" extensions:"x-nullable"` + // OAC_PUBLIC_URL: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset. + PublicURL *string `json:"public_url" extensions:"x-nullable"` + // public_url followed by /v1; null when public_url is null. + APIBaseURL *string `json:"api_base_url" extensions:"x-nullable"` + // True when public_url names a loopback host, reachable only from the Core host. + LocalOnly bool `json:"local_only"` + // Full source commit Core was built from; null for development builds. + SourceCommit *string `json:"source_commit" extensions:"x-nullable"` + // The installer's settings snapshot (OAC_SETTINGS_FILE); null when the installer did not start Core. + Configuration *InstallationConfiguration `json:"configuration" extensions:"x-nullable"` + AddressBindings store.AddressBindings `json:"address_bindings"` +} + +// InstallationConfiguration says where process settings are changed and what +// the last applied values were. Sensitive values are never included. +type InstallationConfiguration struct { + // Absolute host path of the installation's config.json. + Path string `json:"path"` + // Command that applies config.json changes. + ApplyCommand string `json:"apply_command"` + AppliedAt time.Time `json:"applied_at"` + Settings []InstallationSetting `json:"settings"` +} + +type InstallationSetting struct { + // Dotted config.json key, such as ports.core. + Key string `json:"key"` + // Applied value; always null for a sensitive setting. + Value any `json:"value" extensions:"x-nullable"` + Default any `json:"default" extensions:"x-nullable"` + // Present only for a sensitive setting: whether it has a value. + Configured *bool `json:"configured,omitempty"` + // False for settings fixed at installation. + Changeable bool `json:"changeable"` + Sensitive bool `json:"sensitive"` + // Services that restart when the setting changes. + Restarts []string `json:"restarts"` +} + +var installationSettingKey = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`) + +const maxInstallationSettings = 64 << 10 + +// ParseInstallationConfiguration validates the installer's settings snapshot. +// A sensitive setting that carries a value is rejected, so the snapshot cannot +// leak a secret through this read. +func ParseInstallationConfiguration(raw []byte) (*InstallationConfiguration, error) { + invalid := errors.New("OAC_SETTINGS_FILE must contain the installer's settings snapshot") + if len(raw) > maxInstallationSettings { + return nil, invalid + } + var value InstallationConfiguration + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&value) != nil || decoder.Decode(new(any)) != io.EOF { + return nil, invalid + } + if !filepath.IsAbs(value.Path) || value.ApplyCommand == "" || len(value.ApplyCommand) > 4096 || value.AppliedAt.IsZero() || value.Settings == nil { + return nil, invalid + } + seen := make(map[string]bool, len(value.Settings)) + for _, setting := range value.Settings { + if !installationSettingKey.MatchString(setting.Key) || seen[setting.Key] || setting.Restarts == nil || + setting.Sensitive != (setting.Configured != nil) || (setting.Sensitive && (setting.Value != nil || setting.Default != nil)) { + return nil, invalid + } + for _, service := range setting.Restarts { + if !slices.Contains([]string{"core", "web", "database"}, service) { + return nil, invalid + } + } + seen[setting.Key] = true + } + return &value, nil +} + +// WithInstallation serves GET /core/v1/installation. bindings counts what is +// bound to the current public URL. +func WithInstallation(value Installation, bindings func(context.Context) (store.AddressBindings, error)) Option { + return func(h *Handler) { + value.Object = "core.installation" + h.installation, h.installationBindings = &value, bindings + } +} + +// @Summary Retrieve installation facts and process settings +// @Description Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Success 200 {object} api.Installation +// @Failure 401,500 {object} CoreErrorResponse +// @Router /core/v1/installation [get] +func (h *Handler) getInstallation(w http.ResponseWriter, r *http.Request) { + bindings, err := h.installationBindings(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + value := *h.installation + value.AddressBindings = bindings + writeJSON(w, http.StatusOK, value) +} diff --git a/services/agents-api/internal/api/installation_test.go b/services/core/internal/api/installation_test.go similarity index 97% rename from services/agents-api/internal/api/installation_test.go rename to services/core/internal/api/installation_test.go index e8fa97d69..d8e780681 100644 --- a/services/agents-api/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { diff --git a/services/agents-api/internal/api/items.go b/services/core/internal/api/items.go similarity index 100% rename from services/agents-api/internal/api/items.go rename to services/core/internal/api/items.go diff --git a/services/agents-api/internal/api/items_test.go b/services/core/internal/api/items_test.go similarity index 93% rename from services/agents-api/internal/api/items_test.go rename to services/core/internal/api/items_test.go index 99caf60cf..546a33f96 100644 --- a/services/agents-api/internal/api/items_test.go +++ b/services/core/internal/api/items_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type itemReadStore struct { diff --git a/services/agents-api/internal/api/json_members.go b/services/core/internal/api/json_members.go similarity index 100% rename from services/agents-api/internal/api/json_members.go rename to services/core/internal/api/json_members.go diff --git a/services/agents-api/internal/api/json_members_test.go b/services/core/internal/api/json_members_test.go similarity index 100% rename from services/agents-api/internal/api/json_members_test.go rename to services/core/internal/api/json_members_test.go diff --git a/services/agents-api/internal/api/json_request.go b/services/core/internal/api/json_request.go similarity index 99% rename from services/agents-api/internal/api/json_request.go rename to services/core/internal/api/json_request.go index fb24d4d4f..e1f680166 100644 --- a/services/agents-api/internal/api/json_request.go +++ b/services/core/internal/api/json_request.go @@ -12,7 +12,7 @@ import ( "unicode/utf16" "unicode/utf8" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/echotext" ) // readJSONBody reads a bounded raw body. Agents API JSON routes use diff --git a/services/agents-api/internal/api/json_request_test.go b/services/core/internal/api/json_request_test.go similarity index 99% rename from services/agents-api/internal/api/json_request_test.go rename to services/core/internal/api/json_request_test.go index 9dc3a041d..955063a0a 100644 --- a/services/agents-api/internal/api/json_request_test.go +++ b/services/core/internal/api/json_request_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/list_query_errors.go b/services/core/internal/api/list_query_errors.go similarity index 100% rename from services/agents-api/internal/api/list_query_errors.go rename to services/core/internal/api/list_query_errors.go diff --git a/services/agents-api/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go similarity index 98% rename from services/agents-api/internal/api/mcp_configuration.go rename to services/core/internal/api/mcp_configuration.go index ba113326a..e856bdb55 100644 --- a/services/agents-api/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) const mcpHTTPOnly = "MCP currently supports HTTP transport only." diff --git a/services/agents-api/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go similarity index 100% rename from services/agents-api/internal/api/mcp_configuration_test.go rename to services/core/internal/api/mcp_configuration_test.go diff --git a/services/agents-api/internal/api/model_configuration_route_test.go b/services/core/internal/api/model_configuration_route_test.go similarity index 100% rename from services/agents-api/internal/api/model_configuration_route_test.go rename to services/core/internal/api/model_configuration_route_test.go diff --git a/services/core/internal/api/model_provider_fixture_test.go b/services/core/internal/api/model_provider_fixture_test.go new file mode 100644 index 000000000..c7bf955a2 --- /dev/null +++ b/services/core/internal/api/model_provider_fixture_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// Hosted and self-hosted Sessions must freeze a model provider. Tests that +// exercise other behavior supply these fixtures instead of relaxing that check. + +// fixtureModelProvider returns a valid bundle for the harness. +func fixtureModelProvider(harness string) *v1.ModelProviderInput { + if harness == "codex" { + return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} + } + return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} +} + +// withFixtureDeploymentProvider configures a deployment default for every harness. +func withFixtureDeploymentProvider() Option { + return WithModelProviderDefaults(func(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { + return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: fixtureModelProvider(harness), Revision: uuid.New()}, nil + }) +} + +// fixtureSessionProvider is a top-level Session request member for Codex. +const fixtureSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.fixture.example/v1","api_key":"fixture-model-key"}}` + +// fixtureAnthropicSessionProvider is the Claude Code and MiniMax Code equivalent. +const fixtureAnthropicSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"anthropic","base_url":"https://model.fixture.example/anthropic","api_key":"fixture-model-key","context_window":200000,"max_output_tokens":8000}}` diff --git a/services/agents-api/internal/api/model_provider_public_validation_test.go b/services/core/internal/api/model_provider_public_validation_test.go similarity index 100% rename from services/agents-api/internal/api/model_provider_public_validation_test.go rename to services/core/internal/api/model_provider_public_validation_test.go diff --git a/services/agents-api/internal/api/native_classification_integration_test.go b/services/core/internal/api/native_classification_integration_test.go similarity index 93% rename from services/agents-api/internal/api/native_classification_integration_test.go rename to services/core/internal/api/native_classification_integration_test.go index a6ec85e04..4eaabd9d6 100644 --- a/services/agents-api/internal/api/native_classification_integration_test.go +++ b/services/core/internal/api/native_classification_integration_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/pagination.go b/services/core/internal/api/pagination.go similarity index 100% rename from services/agents-api/internal/api/pagination.go rename to services/core/internal/api/pagination.go diff --git a/services/agents-api/internal/api/pagination_test.go b/services/core/internal/api/pagination_test.go similarity index 100% rename from services/agents-api/internal/api/pagination_test.go rename to services/core/internal/api/pagination_test.go diff --git a/services/agents-api/internal/api/project_api_key_configuration.go b/services/core/internal/api/project_api_key_configuration.go similarity index 100% rename from services/agents-api/internal/api/project_api_key_configuration.go rename to services/core/internal/api/project_api_key_configuration.go diff --git a/services/core/internal/api/project_api_keys.go b/services/core/internal/api/project_api_keys.go new file mode 100644 index 000000000..a6dac8fc3 --- /dev/null +++ b/services/core/internal/api/project_api_keys.go @@ -0,0 +1,290 @@ +package api + +import ( + "context" + "encoding/hex" + "net/http" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type ProjectAPIKeyStore interface { + CreateProject(context.Context, string, string) (store.Project, error) + GetProject(context.Context, string) (store.ProjectBinding, error) + ListProjects(context.Context, string, int, bool) (store.ProjectPage, error) + RenameProject(context.Context, string, string) (store.Project, error) + ArchiveProject(context.Context, string) (store.Project, error) + CreateProjectAPIKey(context.Context, string, string, string) (store.IssuedProjectAPIKey, error) + ListProjectAPIKeys(context.Context, string, string, int, bool) (store.ProjectAPIKeyPage, error) + RevokeProjectAPIKey(context.Context, string, string) error + ResolveProjectAPIKey(context.Context, string) (store.ProjectAPIKeyBinding, error) +} +type ProjectRequest struct { + Name string `json:"name"` +} +type ProjectAPIKeyRequest struct { + Name string `json:"name"` +} + +func WithProjectAPIKeys(s ProjectAPIKeyStore, auth *DeploymentAuthenticator) Option { + return func(h *Handler) { + h.projectKeys = s + if auth != nil { + h.deploymentAuth = auth + } + } +} +func (h *Handler) registerProjectAPIKeyRoutes(r chi.Router) { + if h.projectKeys == nil { + return + } + r.Get("/projects", h.listProjects) + r.Post("/projects", h.createProject) + r.Post("/projects/{project_id}", h.renameProject) + r.Post("/projects/{project_id}/archive", h.archiveProject) + r.Get("/projects/{project_id}/keys", h.listProjectAPIKeys) + r.Post("/projects/{project_id}/keys", h.createProjectAPIKey) + r.Delete("/projects/{project_id}/keys/{key_id}", h.revokeProjectAPIKey) +} +func (h *Handler) resolveAdminProject(ctx context.Context, id string) (store.ProjectBinding, error) { + return h.projectKeys.GetProject(ctx, id) +} +func (h *Handler) listAdminProjects(ctx context.Context, after string, limit int, ascending bool) (store.ProjectPage, error) { + return h.projectKeys.ListProjects(ctx, after, limit, ascending) +} +func (h *Handler) adminProjectScope(w http.ResponseWriter, r *http.Request) (store.ProjectBinding, bool) { + p, err := h.resolveAdminProject(r.Context(), chi.URLParam(r, "project_id")) + if err != nil { + writeStoreError(w, r, err) + return store.ProjectBinding{}, false + } + setAdminAuditSource(r, p.Project.ID) + return p, true +} +func setAdminAuditSource(r *http.Request, projectID string) { + digest, _ := projectBearerDigest(r) + requestID, _ := log.RequestIDFromContext(r.Context()) + source := adminaudit.Source{CredentialID: hex.EncodeToString(digest[:])[:8], ActorLabel: r.Header.Get("X-Core-Console-Actor"), RequestID: requestID, TraceID: requestID, ProjectID: projectID} + if carrier, ok := log.TraceFromContext(r.Context()); ok { + source.TraceID = carrier.Trace.String() + } + *r = *r.WithContext(adminaudit.WithSource(r.Context(), source)) +} +func adminCatalogPage(r *http.Request) (string, int, bool, error) { + values := r.URL.Query() + limit := 20 + ascending := false + for _, name := range []string{"after", "limit", "order"} { + if len(values[name]) > 1 { + return "", 0, false, store.ErrInvalidInput + } + } + if raw, ok := values["limit"]; ok { + n, err := strconv.Atoi(raw[0]) + if err != nil || n < 1 || n > 100 { + return "", 0, false, store.ErrInvalidInput + } + limit = n + } + if raw, ok := values["order"]; ok { + if raw[0] != "asc" && raw[0] != "desc" { + return "", 0, false, store.ErrInvalidInput + } + ascending = raw[0] == "asc" + } + return values.Get("after"), limit, ascending, nil +} + +// @Summary List Projects and active key counts +// @Tags Administrator Projects +// @Produce json +// @Security DeploymentAdminAuth +// @Param after query string false "Project ID cursor" +// @Param limit query int false "Page size (1-100)" +// @Param order query string false "asc or desc by Project ID" +// @Success 200 {object} store.ProjectPage +// @Failure 400,401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects [get] +func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { + after, limit, ascending, err := adminCatalogPage(r) + if err != nil { + writeStoreError(w, r, err) + return + } + page, err := h.listAdminProjects(r.Context(), after, limit, ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 200, page) +} + +// @Summary Create an empty independent Project +// @Tags Administrator Projects +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param body body api.ProjectRequest true "Project display name" +// @Success 201 {object} store.Project +// @Failure 400,401,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects [post] +func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBodyLimit(w, r, 4096, "Project request is too large.") + if !ok { + return + } + var input ProjectRequest + if decodeInputObject(raw, &input, "name") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + id := uuid.NewString() + setAdminAuditSource(r, id) + p, err := h.projectKeys.CreateProject(r.Context(), id, input.Name) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 201, p) +} + +// @Summary Rename a Project +// @Tags Administrator Projects +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param body body api.ProjectRequest true "Project display name" +// @Success 200 {object} store.Project +// @Failure 400,401,404,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id} [post] +func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + raw, ok := readJSONBodyLimit(w, r, 4096, "Project request is too large.") + if !ok { + return + } + var input ProjectRequest + if decodeInputObject(raw, &input, "name") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + p, err := h.projectKeys.RenameProject(r.Context(), binding.Project.ID, input.Name) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 200, p) +} + +// @Summary Archive a Project and revoke all its keys while retaining assets +// @Tags Administrator Projects +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Success 200 {object} store.Project +// @Failure 401,404,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/archive [post] +func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + p, err := h.projectKeys.ArchiveProject(r.Context(), binding.Project.ID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 200, p) +} + +// @Summary List safe key metadata for a Project +// @Tags Administrator Projects +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param after query string false "Key ID cursor" +// @Param limit query int false "Page size (1-100)" +// @Param order query string false "asc or desc by key ID" +// @Success 200 {object} store.ProjectAPIKeyPage +// @Failure 400,401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/keys [get] +func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + after, limit, ascending, err := adminCatalogPage(r) + if err != nil { + writeStoreError(w, r, err) + return + } + page, err := h.projectKeys.ListProjectAPIKeys(r.Context(), binding.Project.ID, after, limit, ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 200, page) +} + +// @Summary Issue an independent secret in an existing Project +// @Tags Administrator Projects +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param body body api.ProjectAPIKeyRequest true "Key display name" +// @Success 201 {object} store.IssuedProjectAPIKey +// @Failure 400,401,404,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/keys [post] +func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + raw, ok := readJSONBodyLimit(w, r, 4096, "API key request is too large.") + if !ok { + return + } + var input ProjectAPIKeyRequest + if decodeInputObject(raw, &input, "name") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + key, err := h.projectKeys.CreateProjectAPIKey(r.Context(), binding.Project.ID, uuid.NewString(), input.Name) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 201, key) +} + +// @Summary Revoke one Project key while retaining shared assets +// @Tags Administrator Projects +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param key_id path string true "API key UUID" +// @Success 200 {object} api.SandboxMutationResponse +// @Failure 401,404,409,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/keys/{key_id} [delete] +func (h *Handler) revokeProjectAPIKey(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + id := chi.URLParam(r, "key_id") + if err := h.projectKeys.RevokeProjectAPIKey(r.Context(), binding.Project.ID, id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, 200, SandboxMutationResponse{ID: id, Deleted: true}) +} diff --git a/services/core/internal/api/project_api_keys_test.go b/services/core/internal/api/project_api_keys_test.go new file mode 100644 index 000000000..73f285eb6 --- /dev/null +++ b/services/core/internal/api/project_api_keys_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type projectKeyStoreFixture struct { + ProjectAPIKeyStore + binding store.ProjectAPIKeyBinding + project store.ProjectBinding + resolve error + lookups int +} + +func (s *projectKeyStoreFixture) GetProject(_ context.Context, id string) (store.ProjectBinding, error) { + if s.project.Project.ID == id { + return s.project, nil + } + return store.ProjectBinding{}, store.ErrNotFound +} + +func (s *projectKeyStoreFixture) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { + s.lookups++ + if s.resolve != nil { + return store.ProjectAPIKeyBinding{}, s.resolve + } + if digest != device.HashCredential("issued-project-key") { + return store.ProjectAPIKeyBinding{}, store.ErrNotFound + } + return s.binding, nil +} +func projectKeyHTTP(h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} +func TestAdminCredentialNeverAuthenticatesPublicAPI(t *testing.T) { + key := callerBinding() + auth, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + admin, err := NewDeploymentAuthenticator([]string{key.TokenSHA256}) + if err != nil { + t.Fatal(err) + } + h := &Handler{auth: auth, deploymentAuth: admin} + r := httptest.NewRequest("GET", "/v1/files", nil) + r.Header.Set("Authorization", "Bearer caller") + _, _, ok, err := h.resolveCaller(r) + if ok || err != nil { + t.Fatal("administrator authenticated on public API") + } +} +func TestDatabaseResolverControlsAuthentication(t *testing.T) { + p := callerBinding() + fixture, _ := NewAuthenticator([]APIKey{p}) + binding, _ := fixture.keys.ResolveProjectAPIKey(t.Context(), p.TokenSHA256) + keys := &projectKeyStoreFixture{binding: binding} + auth, _ := NewDatabaseAuthenticator(keys) + h := &Handler{auth: auth} + r := httptest.NewRequest("GET", "/v1/files", nil) + r.Header.Set("Authorization", "Bearer issued-project-key") + got, _, ok, err := h.resolveCaller(r) + if err != nil || !ok || got != binding.Principal { + t.Fatal("database key rejected") + } + keys.resolve = store.ErrNotFound + _, _, ok, err = h.resolveCaller(r) + if err != nil || ok { + t.Fatal("revoked database key authenticated") + } + keys.resolve = errors.New("database unavailable") + w := projectKeyHTTP(h.authenticateCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unavailable auth reached handler") }), true), "GET", "/v1/files", "issued-project-key", "") + if w.Code != 503 { + t.Fatal("database failure did not fail closed") + } +} + +type separationFixture struct { + digests []string + err error +} + +func (s *separationFixture) ValidateProjectKeySeparation(_ context.Context, digests []string) error { + s.digests = digests + return s.err +} +func TestAdministratorCredentialSeparation(t *testing.T) { + s := &separationFixture{} + if err := ValidateCredentialSeparation(t.Context(), nil, s); err == nil { + t.Fatal("missing administrator accepted") + } + digest := device.HashCredential("admin") + admin, _ := NewDeploymentAuthenticator([]string{digest}) + if err := ValidateCredentialSeparation(t.Context(), admin, s); err != nil || len(s.digests) != 1 || s.digests[0] != digest { + t.Fatal("administrator digest was not checked against persisted keys", err) + } + s.err = errors.New("credential overlap") + if err := ValidateCredentialSeparation(t.Context(), admin, s); !errors.Is(err, s.err) { + t.Fatal("persisted credential collision accepted") + } +} +func TestAdminCatalogPageLimits(t *testing.T) { + for _, query := range []string{"limit=101", "limit=0", "limit=bad", "limit=1&limit=2", "order=sideways", "order=asc&order=desc", "after=a&after=b"} { + if _, _, _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?"+query, nil)); err == nil { + t.Errorf("invalid page accepted: %s", query) + } + } + _, limit, ascending, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?limit=100&order=asc", nil)) + if err != nil || limit != 100 || !ascending { + t.Fatal("valid maximum page rejected", err) + } +} diff --git a/services/agents-api/internal/api/resource_creation_test.go b/services/core/internal/api/resource_creation_test.go similarity index 95% rename from services/agents-api/internal/api/resource_creation_test.go rename to services/core/internal/api/resource_creation_test.go index 25275b946..05048ad53 100644 --- a/services/agents-api/internal/api/resource_creation_test.go +++ b/services/core/internal/api/resource_creation_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/resource_query_test.go b/services/core/internal/api/resource_query_test.go similarity index 98% rename from services/agents-api/internal/api/resource_query_test.go rename to services/core/internal/api/resource_query_test.go index dd016ff65..cec5331e0 100644 --- a/services/agents-api/internal/api/resource_query_test.go +++ b/services/core/internal/api/resource_query_test.go @@ -12,9 +12,9 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/routing.go b/services/core/internal/api/routing.go similarity index 99% rename from services/agents-api/internal/api/routing.go rename to services/core/internal/api/routing.go index d89125d90..e5cf3e325 100644 --- a/services/agents-api/internal/api/routing.go +++ b/services/core/internal/api/routing.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/routing_test.go b/services/core/internal/api/routing_test.go similarity index 99% rename from services/agents-api/internal/api/routing_test.go rename to services/core/internal/api/routing_test.go index 4dd4c6967..06ae07c4f 100644 --- a/services/agents-api/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -18,10 +18,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) diff --git a/services/core/internal/api/runtime_history.go b/services/core/internal/api/runtime_history.go new file mode 100644 index 000000000..10d4cd8a1 --- /dev/null +++ b/services/core/internal/api/runtime_history.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/go-chi/chi/v5" +) + +const ( + defaultRuntimeHistoryPoints = 120 + runtimeHistoryRequestBudget = 15 * time.Second +) + +type RuntimeHistoryService interface { + Capabilities() runtimehistory.Capabilities + QuerySession(context.Context, string, string, runtimehistory.Range) (runtimehistory.Response, error) +} + +func WithRuntimeHistory(service RuntimeHistoryService) Option { + return func(h *Handler) { h.runtimeHistory = service } +} + +// getRuntimeHistory serves the administrator per-Session history read. +func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { + if h.runtimeHistory == nil { + writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is not configured on this service.") + return + } + capabilities := h.runtimeHistory.Capabilities() + if capabilities.Validate() != nil || !capabilities.Durable() { + writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is not configured on this service.") + return + } + requested, ok := readRuntimeHistoryRange(w, r, capabilities, time.Now().UTC()) + if !ok { + return + } + expectedTenantID := tenantID(r) + expectedSessionID := chi.URLParam(r, "session_id") + ctx, cancel := context.WithTimeout(r.Context(), runtimeHistoryRequestBudget) + defer cancel() + value, err := h.runtimeHistory.QuerySession(ctx, expectedTenantID, expectedSessionID, requested) + if err != nil { + switch { + case errors.Is(err, runtimehistory.ErrInvalidRange): + writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") + case errors.Is(err, runtimehistory.ErrUnsupported): + writeError(w, http.StatusConflict, "runtime_history_unsupported", "Runtime history is not supported for this Session.") + case errors.Is(err, runtimehistory.ErrUnavailable), errors.Is(err, runtimehistory.ErrInvalidResult), errors.Is(err, context.DeadlineExceeded): + log.Ctx(r.Context()).Warn("Runtime history query unavailable") + writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is temporarily unavailable.") + default: + writeStoreError(w, r, err) + } + return + } + response, err := runtimeHistoryResponse(value, expectedTenantID, expectedSessionID, requested) + if err != nil { + log.Ctx(r.Context()).Error("Runtime history response validation failed") + writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is temporarily unavailable.") + return + } + writeJSON(w, http.StatusOK, response) +} + +func readRuntimeHistoryRange(w http.ResponseWriter, r *http.Request, capabilities runtimehistory.Capabilities, now time.Time) (runtimehistory.Range, bool) { + query := r.URL.Query() + for key, values := range query { + if key != "start" && key != "end" && key != "max_points" || len(values) != 1 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime history accepts one start, end and optional max_points value.") + return runtimehistory.Range{}, false + } + } + start, startErr := strconv.ParseInt(query.Get("start"), 10, 64) + end, endErr := strconv.ParseInt(query.Get("end"), 10, 64) + points := defaultRuntimeHistoryPoints + if capabilities.MaximumPoints < points { + points = capabilities.MaximumPoints + } + if raw := query.Get("max_points"); raw != "" { + var err error + points, err = strconv.Atoi(raw) + if err != nil || points < 2 || points > capabilities.MaximumPoints { + writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") + return runtimehistory.Range{}, false + } + } + if startErr != nil || endErr != nil || start < 0 || end <= start || end > now.Unix()+1 || end-start > int64(capabilities.MaximumRange/time.Second) { + writeError(w, http.StatusBadRequest, "invalid_request", "Runtime history range is invalid.") + return runtimehistory.Range{}, false + } + return runtimehistory.Range{Start: time.Unix(start, 0).UTC(), End: time.Unix(end, 0).UTC(), MaxPoints: points}, true +} + +func runtimeHistoryResponse(value runtimehistory.Response, expectedTenantID, expectedSessionID string, expectedRange runtimehistory.Range) (v1.RuntimeHistory, error) { + if err := value.Validate(time.Now().UTC()); err != nil || !value.Durable() || + value.TenantID != expectedTenantID || value.SessionID != expectedSessionID || + !value.Requested.Start.Equal(expectedRange.Start) || !value.Requested.End.Equal(expectedRange.End) || value.Requested.MaxPoints != expectedRange.MaxPoints { + return v1.RuntimeHistory{}, runtimehistory.ErrInvalidResult + } + retainedStart := value.Requested.Start + configuredStart := value.GeneratedAt.Add(-value.Retention) + if configuredStart.After(retainedStart) { + retainedStart = configuredStart + } + if value.RetainedFrom != nil && value.RetainedFrom.After(retainedStart) { + retainedStart = *value.RetainedFrom + } + if retainedStart.After(value.Requested.End) { + retainedStart = value.Requested.End + } + coverage := v1.RuntimeHistoryCoverage{RetainedStart: retainedStart.Unix(), Buckets: make([]v1.RuntimeHistoryCoveragePoint, 0, len(value.Coverage))} + if retainedStart.Before(value.Requested.End) { + duration := value.Requested.End.Sub(retainedStart) + coverage.ExpectedSampleCount = int64(duration / value.SampleInterval) + if duration%value.SampleInterval != 0 { + coverage.ExpectedSampleCount++ + } + } + for _, point := range value.Coverage { + converted := v1.RuntimeHistoryCoveragePoint{ + Start: point.Start.Unix(), End: point.End.Unix(), ObservationCount: point.ObservationCount, + ObservedCount: point.ObservedCount, UnavailableCount: point.UnavailableCount, + } + if point.ObservationCount > 0 { + first, last := point.FirstObservedAt.Unix(), point.LastObservedAt.Unix() + converted.FirstObservedAt, converted.LastObservedAt = &first, &last + if coverage.FirstSampleAt == nil || first < *coverage.FirstSampleAt { + value := first + coverage.FirstSampleAt = &value + } + if coverage.LastSampleAt == nil || last > *coverage.LastSampleAt { + value := last + coverage.LastSampleAt = &value + } + } + coverage.SampleCount += int64(point.ObservationCount) + coverage.Buckets = append(coverage.Buckets, converted) + } + response := v1.RuntimeHistory{ + Object: "agent.runtime_history", Source: "durable", SessionID: value.SessionID, + RequestedRange: v1.RuntimeHistoryRange{Start: value.Requested.Start.Unix(), End: value.Requested.End.Unix()}, + ResolutionSeconds: int64(value.Resolution / time.Second), GeneratedAt: value.GeneratedAt.Unix(), Coverage: coverage, + Series: make([]v1.RuntimeHistorySeries, 0, len(value.Series)), + TokenUsage: make([]v1.RuntimeHistoryTokenUsagePoint, 0, len(value.TokenUsage)), + } + for _, point := range value.TokenUsage { + response.TokenUsage = append(response.TokenUsage, v1.RuntimeHistoryTokenUsagePoint{ + Start: point.Start.Unix(), End: point.End.Unix(), SampledAt: point.SampledAt.Unix(), + InputTokens: point.InputTokens, OutputTokens: point.OutputTokens, + }) + } + for _, series := range value.Series { + converted := v1.RuntimeHistorySeries{ + EnvironmentID: series.EnvironmentID, AllocationID: series.AllocationID, + StartedAt: v1.RuntimeHistoryTime{Seconds: series.StartedAt.Unix(), Nanoseconds: series.StartedAt.Nanosecond()}, ProviderType: series.ProviderType, + Points: make([]v1.RuntimeHistoryPoint, 0, len(series.Points)), + } + for _, point := range series.Points { + item := v1.RuntimeHistoryPoint{ + Start: point.Start.Unix(), End: point.End.Unix(), ObservationCount: point.ObservationCount, + ObservedCount: point.ObservedCount, UnavailableCount: point.UnavailableCount, + } + if point.ObservationCount > 0 { + first, last := point.FirstObservedAt.Unix(), point.LastObservedAt.Unix() + item.FirstObservedAt, item.LastObservedAt = &first, &last + } + if point.CPUContributorCount > 0 { + item.CPU = &v1.RuntimeHistoryCPU{ContributorCount: point.CPUContributorCount, UtilizationRatio: point.CPUUtilizationRatio, CapacityCores: point.CPUCapacityCores} + } + if point.MemoryContributorCount > 0 { + item.Memory = &v1.RuntimeHistoryMemory{ContributorCount: point.MemoryContributorCount, UsageBytes: point.MemoryUsageBytes, LimitBytes: point.MemoryLimitBytes} + } + converted.Points = append(converted.Points, item) + } + response.Series = append(response.Series, converted) + } + return response, nil +} diff --git a/services/core/internal/api/runtime_history_test.go b/services/core/internal/api/runtime_history_test.go new file mode 100644 index 000000000..933f474af --- /dev/null +++ b/services/core/internal/api/runtime_history_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type runtimeHistoryFixture struct { + capabilities runtimehistory.Capabilities + response runtimehistory.Response + err error + tenant string + session string + requested runtimehistory.Range + calls int +} + +func (f *runtimeHistoryFixture) Capabilities() runtimehistory.Capabilities { return f.capabilities } + +func (f *runtimeHistoryFixture) QuerySession(_ context.Context, tenant, session string, requested runtimehistory.Range) (runtimehistory.Response, error) { + f.calls++ + f.tenant, f.session, f.requested = tenant, session, requested + return f.response, f.err +} + +func historyCapabilities(mode runtimehistory.CollectionMode) runtimehistory.Capabilities { + value := runtimehistory.Capabilities{ + CollectionMode: mode, Retention: 7 * 24 * time.Hour, MinimumStep: 30 * time.Second, + MaximumRange: 24 * time.Hour, MaximumPoints: 1_000, MaximumSeries: 64, MaximumTotalPoints: 10_000, + Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, + } + if mode == runtimehistory.CollectionPeriodic { + value.SampleInterval = 30 * time.Second + } + return value +} + +func TestRuntimeHistoryRequiresQualifiedPeriodicCollection(t *testing.T) { + service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionOnRead)} + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") + if response.Code != http.StatusServiceUnavailable || service.calls != 0 { + t.Fatalf("on-read history reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) + } +} + +func TestRuntimeHistoryFailsClosedForMalformedCapabilities(t *testing.T) { + service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} + service.capabilities.Retention = 0 + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") + if response.Code != http.StatusServiceUnavailable || service.calls != 0 { + t.Fatalf("malformed capabilities reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) + } +} + +func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + start := now.Add(-time.Hour) + sessionID, environmentID, allocationID := uuid.NewString(), uuid.NewString(), uuid.NewString() + scope := runtimehistory.Scope{SessionID: sessionID, EnvironmentID: environmentID} + zeroRatio := float64(0) + capacity := 2.0 + zeroMemory := uint64(0) + limit := uint64(2048) + coveragePoint := runtimehistory.CoveragePoint{ + Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), + ObservationCount: 1, ObservedCount: 1, + } + resourcePoint := runtimehistory.Point{ + Start: start, End: start.Add(time.Minute), FirstObservedAt: start.Add(10 * time.Second), LastObservedAt: start.Add(10 * time.Second), + ObservationCount: 1, ObservedCount: 1, CPUContributorCount: 1, MemoryContributorCount: 1, + CPUUtilizationRatio: &zeroRatio, CPUCapacityCores: &capacity, MemoryUsageBytes: &zeroMemory, MemoryLimitBytes: &limit, + } + service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} + handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) + scope.TenantID = tenant + service.response = runtimehistory.Response{ + Capabilities: service.capabilities, Scope: scope, + Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, Resolution: time.Minute, GeneratedAt: now, RetainedFrom: &start, + Coverage: []runtimehistory.CoveragePoint{coveragePoint}, + Series: []runtimehistory.Series{{Scope: scope, AllocationID: allocationID, StartedAt: start.Add(-time.Minute), ProviderType: "docker", Points: []runtimehistory.Point{resourcePoint}}}, + TokenUsage: []runtimehistory.TokenUsagePoint{{Start: start, End: start.Add(time.Minute), SampledAt: start.Add(10 * time.Second), InputTokens: 120, OutputTokens: 30}}, + } + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history?start="+timeString(start)+"&end="+timeString(now)+"&max_points=60") + if response.Code != http.StatusOK { + t.Fatalf("history returned %d: %s", response.Code, response.Body) + } + var value v1.RuntimeHistory + if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history" || value.Source != "durable" || value.SessionID != sessionID || value.ResolutionSeconds != 60 || value.Coverage.SampleCount != 1 || value.Coverage.ExpectedSampleCount != 120 || len(value.Coverage.Buckets) != 1 || len(value.Series) != 1 || len(value.Series[0].Points) != 1 || len(value.TokenUsage) != 1 || value.TokenUsage[0].InputTokens != 120 || value.TokenUsage[0].OutputTokens != 30 { + t.Fatalf("invalid history response: %s", response.Body) + } + point := value.Series[0].Points[0] + if point.CPU == nil || point.CPU.UtilizationRatio == nil || *point.CPU.UtilizationRatio != 0 || point.Memory == nil || point.Memory.UsageBytes == nil || *point.Memory.UsageBytes != 0 { + t.Fatalf("observed zero was lost: %+v", point) + } + if service.calls != 1 || service.tenant != tenant || service.session != sessionID || !service.requested.Start.Equal(start) || !service.requested.End.Equal(now) || service.requested.MaxPoints != 60 { + t.Fatalf("history authority/range mismatch: %+v", service) + } +} + +func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { + service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + sessionID := uuid.NewString() + for _, query := range []string{ + "", "?start=1", "?start=2&end=1", "?start=x&end=2", "?start=1&end=2&provider=docker", "?start=1&start=1&end=2", "?start=1&end=2&max_points=x", "?start=1&end=2&max_points=1", "?start=1&end=2&max_points=1001", "?start=1&end=90002", "?start=1&end=9223372036854775807", + } { + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history"+query) + if response.Code != http.StatusBadRequest { + t.Fatalf("unsafe query %q returned %d: %s", query, response.Code, response.Body) + } + } + if service.calls != 0 { + t.Fatalf("unsafe query reached history service %d times", service.calls) + } + + now := time.Now().UTC().Truncate(time.Second) + path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(now.Add(-time.Hour)) + "&end=" + timeString(now) + for _, tc := range []struct { + err error + code int + }{ + {runtimehistory.ErrInvalidRange, http.StatusBadRequest}, + {runtimehistory.ErrUnsupported, http.StatusConflict}, + {runtimehistory.ErrUnavailable, http.StatusServiceUnavailable}, + {runtimehistory.ErrInvalidResult, http.StatusServiceUnavailable}, + {store.ErrNotFound, http.StatusNotFound}, + } { + service.err = tc.err + response := runtimeObservationRequest(handler, path) + if response.Code != tc.code { + t.Fatalf("history error %v returned %d: %s", tc.err, response.Code, response.Body) + } + } + service.err = nil + service.response = runtimehistory.Response{} + response := runtimeObservationRequest(handler, path) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("malformed history response returned %d: %s", response.Code, response.Body) + } +} + +func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + start := now.Add(-time.Hour) + sessionID := uuid.NewString() + service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} + handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) + base := runtimehistory.Response{ + Capabilities: service.capabilities, + Scope: runtimehistory.Scope{ + TenantID: tenant, SessionID: sessionID, EnvironmentID: uuid.NewString(), + }, + Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, + Resolution: time.Minute, GeneratedAt: now, + } + path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(start) + "&end=" + timeString(now) + "&max_points=60" + + for name, mutate := range map[string]func(*runtimehistory.Response){ + "tenant": func(value *runtimehistory.Response) { value.TenantID = uuid.NewString() }, + "Session": func(value *runtimehistory.Response) { value.SessionID = uuid.NewString() }, + "range": func(value *runtimehistory.Response) { + value.Requested.Start = value.Requested.Start.Add(30 * time.Second) + }, + } { + t.Run(name, func(t *testing.T) { + service.response = base + mutate(&service.response) + response := runtimeObservationRequest(handler, path) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("mismatched %s response returned %d: %s", name, response.Code, response.Body) + } + }) + } +} + +func TestRuntimeHistoryDefaultPointBudgetRespectsCapabilities(t *testing.T) { + capabilities := historyCapabilities(runtimehistory.CollectionPeriodic) + capabilities.MaximumPoints = 60 + service := &runtimeHistoryFixture{capabilities: capabilities, err: runtimehistory.ErrUnavailable} + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + now := time.Now().UTC().Truncate(time.Second) + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start="+timeString(now.Add(-time.Hour))+"&end="+timeString(now)) + if response.Code != http.StatusServiceUnavailable || service.calls != 1 || service.requested.MaxPoints != 60 { + t.Fatalf("default point budget ignored capabilities: status=%d calls=%d requested=%+v", response.Code, service.calls, service.requested) + } +} + +func TestRuntimeHistoryExpectedCoverageUsesOverflowSafeCeiling(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + start := now.Add(-time.Hour) + huge := (time.Duration(1<<63-1) / time.Second) * time.Second + capabilities := historyCapabilities(runtimehistory.CollectionPeriodic) + capabilities.SampleInterval = huge + capabilities.Retention = huge + capabilities.MaximumRange = time.Hour + value := runtimehistory.Response{ + Capabilities: capabilities, + Scope: runtimehistory.Scope{ + TenantID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), + }, + Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, + Resolution: time.Minute, + GeneratedAt: now, + } + response, err := runtimeHistoryResponse(value, value.TenantID, value.SessionID, value.Requested) + if err != nil || response.Coverage.ExpectedSampleCount != 1 { + t.Fatalf("unsafe expected sample ceiling: count=%d err=%v", response.Coverage.ExpectedSampleCount, err) + } +} + +func timeString(value time.Time) string { return fmt.Sprintf("%d", value.Unix()) } diff --git a/services/agents-api/internal/api/runtime_observations.go b/services/core/internal/api/runtime_observations.go similarity index 97% rename from services/agents-api/internal/api/runtime_observations.go rename to services/core/internal/api/runtime_observations.go index 53fe0086d..b6a59585b 100644 --- a/services/agents-api/internal/api/runtime_observations.go +++ b/services/core/internal/api/runtime_observations.go @@ -7,8 +7,8 @@ import ( "regexp" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/runtime_observations_test.go b/services/core/internal/api/runtime_observations_test.go similarity index 98% rename from services/agents-api/internal/api/runtime_observations_test.go rename to services/core/internal/api/runtime_observations_test.go index 9b0169067..235a66607 100644 --- a/services/agents-api/internal/api/runtime_observations_test.go +++ b/services/core/internal/api/runtime_observations_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/sandbox_deployment_changes_test.go b/services/core/internal/api/sandbox_deployment_changes_test.go similarity index 97% rename from services/agents-api/internal/api/sandbox_deployment_changes_test.go rename to services/core/internal/api/sandbox_deployment_changes_test.go index 5227157a1..b2b4d9397 100644 --- a/services/agents-api/internal/api/sandbox_deployment_changes_test.go +++ b/services/core/internal/api/sandbox_deployment_changes_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) { diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go new file mode 100644 index 000000000..570408314 --- /dev/null +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -0,0 +1,241 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// SandboxE2BInput is write-only provider configuration. Safe responses use the +// store's separate deployment view and never serialize this request. +type SandboxE2BInput struct { + APIKey *string `json:"api_key,omitempty"` + APIURL string `json:"api_url,omitempty"` + Domain string `json:"domain,omitempty"` + Template string `json:"template"` +} + +type SandboxDeploymentInput struct { + ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` + // Per-sandbox limits, required for Docker and microsandbox. E2B may omit + // them; Core then uses the validated template build's cpus and memory_mib. + Resources sandbox.Resources `json:"resources"` + Runtime *sandbox.RuntimeRelease `json:"runtime,omitempty"` + Provider string `json:"provider"` + E2B *SandboxE2BInput `json:"e2b,omitempty"` +} + +type SandboxDeploymentChangeInput struct { + SandboxDeploymentInput +} + +func (v SandboxDeploymentInput) request() store.SandboxDeploymentSetupRequest { + input := store.SandboxDeploymentSetupRequest{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Runtime: v.Runtime}} + if v.E2B != nil { + input.E2B = &sandbox.E2BConfiguration{Template: v.E2B.Template, APIURL: v.E2B.APIURL, Domain: v.E2B.Domain} + if v.E2B.APIKey != nil { + input.E2B.APIKey = *v.E2B.APIKey + input.E2B.ReplaceCredential = true + } + } + return input +} + +// rejectCoreURL names the retired member instead of reporting a generic unknown +// member: Core derives core_url from the installation public URL. +func rejectCoreURL(w http.ResponseWriter, raw json.RawMessage) bool { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return false + } + if _, present := fields["core_url"]; !present { + return false + } + writeError(w, http.StatusBadRequest, "invalid_request_error", "core_url is derived from the installation public URL (public_url in config.json, OAC_PUBLIC_URL for Core) and cannot be set here. Remove it.", "core_url") + return true +} + +func WithSandboxDeploymentSetup(initialize func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error)) Option { + return func(h *Handler) { h.sandboxSetup = initialize } +} + +func WithSandboxDeploymentChanges( + update func(context.Context, store.SandboxDeploymentUpdateRequest) (store.RuntimeDeploymentView, error), + reset func(context.Context, store.SandboxResetRequest) (store.RuntimeDeploymentView, error), + cancel func(context.Context, uint64) (store.RuntimeDeploymentView, error), +) Option { + return func(h *Handler) { h.sandboxUpdate = update; h.sandboxReset = reset; h.sandboxResetCancel = cancel } +} + +// @Summary Initialize the deployment sandbox provider +// @Description Selects a provider, enforced resource limits and pinned Runtime release. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; differing selections and file-managed deployments reject. This does not create compute or execute work. +// @Tags Sandbox Manager +// @Produce json +// @Security DeploymentAdminAuth +// @Accept json +// @Param body body api.SandboxDeploymentInput true "Deployment selection" +// @Success 200 {object} store.RuntimeDeploymentView +// @Failure 400,401,409,500,503 {object} CoreErrorResponse +// @Router /core/v1/sandbox/deployment [post] +func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBody(w, r) + if !ok { + return + } + if rejectCoreURL(w, raw) { + return + } + var input SandboxDeploymentInput + if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if h.sandboxSetup == nil { + writeStoreError(w, r, store.ErrSandboxDeploymentConflict) + return + } + result, err := h.sandboxSetup(r.Context(), input.request()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary Change the sandbox deployment configuration +// @Description Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. +// @Tags Sandbox Manager +// @Produce json +// @Security DeploymentAdminAuth +// @Accept json +// @Param body body api.SandboxDeploymentChangeInput true "Replacement deployment selection" +// @Success 200 {object} store.RuntimeDeploymentView +// @Failure 400,401,409,500,503 {object} CoreErrorResponse +// @Router /core/v1/sandbox/deployment [put] +func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBody(w, r) + if !ok { + return + } + if rejectCoreURL(w, raw) { + return + } + var input SandboxDeploymentChangeInput + if decodeInputObject(raw, &input, "provider", "e2b", "resources", "runtime", "expected_generation") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if h.sandboxUpdate == nil { + writeStoreError(w, r, store.ErrSandboxDeploymentConflict) + return + } + result, err := h.sandboxUpdate(r.Context(), store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input.request(), ExpectedGeneration: *input.ExpectedGeneration}) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary Start or escalate a durable sandbox deployment reset +// @Description Archives hosted Sessions and waits for confirmed provider cleanup, preserving history and Files/Artifacts. Auto waits for started or waiting Turns and file writes until the durable deadline; force cancels them. The same clear is idempotent; force escalates auto. Requires the current generation. Self-hosted Sessions are unchanged. +// @Tags Sandbox Manager +// @Produce json +// @Security DeploymentAdminAuth +// @Accept json +// @Param body body store.SandboxResetRequest true "Reset mode and current deployment generation" +// @Success 200 {object} store.RuntimeDeploymentView +// @Failure 400,401,409,500,503 {object} CoreErrorResponse +// @Router /core/v1/sandbox/deployment/reset [post] +func (h *Handler) startSandboxReset(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONBodyLimit(w, r, 4096, "Reset request is too large.") + if !ok { + return + } + var input struct { + ExpectedGeneration *uint64 `json:"expected_generation"` + Clear string `json:"clear"` + DeadlineSeconds *int32 `json:"deadline_seconds"` + } + if decodeInputObject(raw, &input, "expected_generation", "clear", "deadline_seconds") != nil || input.ExpectedGeneration == nil || nullSandboxKey(raw) { + writeError(w, http.StatusBadRequest, "invalid_request_error", "A current expected_generation is required.", "expected_generation") + return + } + if input.Clear != "auto" && input.Clear != "force" { + writeError(w, http.StatusBadRequest, "invalid_request_error", "Choose auto or force for clear.", "clear") + return + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + if value, present := fields["deadline_seconds"]; present && string(value) == "null" { + writeError(w, http.StatusBadRequest, "invalid_request_error", "deadline_seconds must be an integer when supplied.", "deadline_seconds") + return + } + if input.DeadlineSeconds != nil && (input.Clear != "auto" || *input.DeadlineSeconds < 300 || *input.DeadlineSeconds > 86400) { + writeCoreError(w, http.StatusBadRequest, "invalid_request_error", "deadline_seconds applies only to auto and must be between 300 and 86400.", CoreErrorDetails{"min": CoreErrorNumber(300), "max": CoreErrorNumber(86400)}, "deadline_seconds") + return + } + if h.sandboxReset == nil { + writeStoreError(w, r, store.ErrSandboxDeploymentConflict) + return + } + setAdminAuditSource(r, "") + result, err := h.sandboxReset(r.Context(), store.SandboxResetRequest{ExpectedGeneration: *input.ExpectedGeneration, Clear: input.Clear, DeadlineSeconds: input.DeadlineSeconds}) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary Cancel a sandbox deployment reset +// @Description Restores admission but never restores Sessions already archived. With no reset running this is an idempotent read, provided the generation still matches. +// @Tags Sandbox Manager +// @Produce json +// @Security DeploymentAdminAuth +// @Param expected_generation query integer true "Current deployment generation" +// @Success 200 {object} store.RuntimeDeploymentView +// @Failure 400,401,409,500,503 {object} CoreErrorResponse +// @Router /core/v1/sandbox/deployment/reset [delete] +func (h *Handler) cancelSandboxReset(w http.ResponseWriter, r *http.Request) { + query, err := parseResetGeneration(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", "A single current expected_generation is required.", "expected_generation") + return + } + if h.sandboxResetCancel == nil { + writeStoreError(w, r, store.ErrSandboxDeploymentConflict) + return + } + setAdminAuditSource(r, "") + result, err := h.sandboxResetCancel(r.Context(), query) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} +func parseResetGeneration(r *http.Request) (uint64, error) { + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(query) != 1 || len(query["expected_generation"]) != 1 { + return 0, store.ErrInvalidInput + } + return strconv.ParseUint(query.Get("expected_generation"), 10, 64) +} + +// Null is an invalid explicit credential, not the omitted-key preservation path. +func nullSandboxKey(raw json.RawMessage) bool { + var body struct { + E2B map[string]json.RawMessage `json:"e2b"` + } + if json.Unmarshal(raw, &body) != nil { + return false + } + key, present := body.E2B["api_key"] + return present && string(key) == "null" +} diff --git a/services/core/internal/api/sandbox_deployment_setup_test.go b/services/core/internal/api/sandbox_deployment_setup_test.go new file mode 100644 index 000000000..cf43aa16c --- /dev/null +++ b/services/core/internal/api/sandbox_deployment_setup_test.go @@ -0,0 +1,60 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func TestSandboxDeploymentSetupRequiresAdministratorAndStrictBody(t *testing.T) { + project, _ := NewAuthenticator([]APIKey{callerBinding()}) + admin, _ := NewDeploymentAuthenticator([]string{device.HashCredential("administrator")}) + calls := 0 + initialize := func(_ context.Context, input store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) { + calls++ + if input.Provider == "microsandbox" { + return store.RuntimeDeploymentView{}, store.ErrSandboxDeploymentConflict + } + return store.RuntimeDeploymentView{Provider: input.Provider}, nil + } + h, err := NewHandler(&recordingStore{}, project, "codex", WithSandboxManager(&store.Store{}, admin), WithSandboxDeploymentSetup(initialize)) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + token, body string + status, calls int + }{ + {"caller", `{"provider":"docker","expected_generation":0}`, 401, 0}, + {"node-credential", `{"provider":"docker","expected_generation":0}`, 401, 0}, + {"enrollment-token", `{"provider":"docker","expected_generation":0}`, 401, 0}, + {"administrator", `{"provider":"docker","unexpected":true}`, 400, 0}, + {"administrator", `{"provider":"docker"}`, 400, 0}, + {"administrator", `{"provider":"docker","expected_generation":null}`, 400, 0}, + {"administrator", `{"provider":"docker","expected_generation":0}`, 200, 1}, + {"administrator", `{"provider":"microsandbox","expected_generation":0}`, 409, 2}, + } { + request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(test.body)) + request.Header.Set("Authorization", "Bearer "+test.token) + result := httptest.NewRecorder() + h.ServeHTTP(result, request) + if result.Code != test.status || calls != test.calls { + t.Fatal(result.Code, calls, result.Body.String()) + } + } +} + +func TestSandboxDeploymentSetupFileModeReturnsConflict(t *testing.T) { + h := &Handler{} + request := httptest.NewRequest(http.MethodPost, "/core/v1/sandbox/deployment", strings.NewReader(`{"provider":"docker","expected_generation":0}`)) + result := httptest.NewRecorder() + h.initializeSandboxDeployment(result, request) + if result.Code != http.StatusConflict || !strings.Contains(result.Body.String(), "sandbox_deployment_conflict") { + t.Fatal(result.Code, result.Body.String()) + } +} diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery.go b/services/core/internal/api/sandbox_e2b_discovery.go similarity index 95% rename from services/agents-api/internal/api/sandbox_e2b_discovery.go rename to services/core/internal/api/sandbox_e2b_discovery.go index 577852b67..a25f9abd7 100644 --- a/services/agents-api/internal/api/sandbox_e2b_discovery.go +++ b/services/core/internal/api/sandbox_e2b_discovery.go @@ -5,8 +5,8 @@ import ( "errors" "net/http" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/sandbox_e2b_discovery_test.go b/services/core/internal/api/sandbox_e2b_discovery_test.go similarity index 90% rename from services/agents-api/internal/api/sandbox_e2b_discovery_test.go rename to services/core/internal/api/sandbox_e2b_discovery_test.go index 6c2d1b2a7..6af45b696 100644 --- a/services/agents-api/internal/api/sandbox_e2b_discovery_test.go +++ b/services/core/internal/api/sandbox_e2b_discovery_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go similarity index 99% rename from services/agents-api/internal/api/sandbox_manager.go rename to services/core/internal/api/sandbox_manager.go index d88883ce7..ef954f809 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -4,7 +4,7 @@ import ( "net/http" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/sandbox_manager_auth.go b/services/core/internal/api/sandbox_manager_auth.go similarity index 100% rename from services/agents-api/internal/api/sandbox_manager_auth.go rename to services/core/internal/api/sandbox_manager_auth.go diff --git a/services/agents-api/internal/api/sandbox_manager_test.go b/services/core/internal/api/sandbox_manager_test.go similarity index 97% rename from services/agents-api/internal/api/sandbox_manager_test.go rename to services/core/internal/api/sandbox_manager_test.go index 2c119aaf7..e28c4b65d 100644 --- a/services/agents-api/internal/api/sandbox_manager_test.go +++ b/services/core/internal/api/sandbox_manager_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSandboxAdministratorIsSeparateFromProject(t *testing.T) { diff --git a/services/agents-api/internal/api/sandbox_node_configuration.go b/services/core/internal/api/sandbox_node_configuration.go similarity index 96% rename from services/agents-api/internal/api/sandbox_node_configuration.go rename to services/core/internal/api/sandbox_node_configuration.go index cdd6370e1..94e55a429 100644 --- a/services/agents-api/internal/api/sandbox_node_configuration.go +++ b/services/core/internal/api/sandbox_node_configuration.go @@ -1,7 +1,7 @@ package api import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "math" "net/http" "strconv" diff --git a/services/agents-api/internal/api/sandbox_node_configuration_test.go b/services/core/internal/api/sandbox_node_configuration_test.go similarity index 100% rename from services/agents-api/internal/api/sandbox_node_configuration_test.go rename to services/core/internal/api/sandbox_node_configuration_test.go diff --git a/services/agents-api/internal/api/sandbox_node_detail.go b/services/core/internal/api/sandbox_node_detail.go similarity index 94% rename from services/agents-api/internal/api/sandbox_node_detail.go rename to services/core/internal/api/sandbox_node_detail.go index b7e6a581c..f846efa09 100644 --- a/services/agents-api/internal/api/sandbox_node_detail.go +++ b/services/core/internal/api/sandbox_node_detail.go @@ -6,7 +6,7 @@ import ( "net/url" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/sandbox_node_detail_test.go b/services/core/internal/api/sandbox_node_detail_test.go similarity index 88% rename from services/agents-api/internal/api/sandbox_node_detail_test.go rename to services/core/internal/api/sandbox_node_detail_test.go index bcad20c2b..f19402477 100644 --- a/services/agents-api/internal/api/sandbox_node_detail_test.go +++ b/services/core/internal/api/sandbox_node_detail_test.go @@ -1,8 +1,8 @@ package api import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "testing" ) diff --git a/services/agents-api/internal/api/sandbox_selector_test.go b/services/core/internal/api/sandbox_selector_test.go similarity index 95% rename from services/agents-api/internal/api/sandbox_selector_test.go rename to services/core/internal/api/sandbox_selector_test.go index 15af1f351..dbe6ee3a5 100644 --- a/services/agents-api/internal/api/sandbox_selector_test.go +++ b/services/core/internal/api/sandbox_selector_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/saved_configuration.go b/services/core/internal/api/saved_configuration.go similarity index 97% rename from services/agents-api/internal/api/saved_configuration.go rename to services/core/internal/api/saved_configuration.go index 8c372eaec..e2e726748 100644 --- a/services/agents-api/internal/api/saved_configuration.go +++ b/services/core/internal/api/saved_configuration.go @@ -8,8 +8,8 @@ import ( "slices" "unicode/utf8" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func resolveSavedAgent(input v1.CreateAgentRequest) (store.CreateAgentInput, error) { diff --git a/services/agents-api/internal/api/saved_core_input.go b/services/core/internal/api/saved_core_input.go similarity index 93% rename from services/agents-api/internal/api/saved_core_input.go rename to services/core/internal/api/saved_core_input.go index f09b62220..cb3dbcfbd 100644 --- a/services/agents-api/internal/api/saved_core_input.go +++ b/services/core/internal/api/saved_core_input.go @@ -3,7 +3,7 @@ package api import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" ) // Validate presence before decoding: a supplied empty/null harness is invalid, diff --git a/services/agents-api/internal/api/saved_provider_test.go b/services/core/internal/api/saved_provider_test.go similarity index 98% rename from services/agents-api/internal/api/saved_provider_test.go rename to services/core/internal/api/saved_provider_test.go index 2c9fc4a21..5565d27fb 100644 --- a/services/agents-api/internal/api/saved_provider_test.go +++ b/services/core/internal/api/saved_provider_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/saved_tools.go b/services/core/internal/api/saved_tools.go similarity index 95% rename from services/agents-api/internal/api/saved_tools.go rename to services/core/internal/api/saved_tools.go index 844c4c637..707e23253 100644 --- a/services/agents-api/internal/api/saved_tools.go +++ b/services/core/internal/api/saved_tools.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { diff --git a/services/agents-api/internal/api/saved_web_search_test.go b/services/core/internal/api/saved_web_search_test.go similarity index 100% rename from services/agents-api/internal/api/saved_web_search_test.go rename to services/core/internal/api/saved_web_search_test.go diff --git a/services/agents-api/internal/api/session_admission_test.go b/services/core/internal/api/session_admission_test.go similarity index 97% rename from services/agents-api/internal/api/session_admission_test.go rename to services/core/internal/api/session_admission_test.go index a9f62344d..7b0c73700 100644 --- a/services/agents-api/internal/api/session_admission_test.go +++ b/services/core/internal/api/session_admission_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func TestSessionAdmissionRejectsBeforeResourceOrExecutionAccess(t *testing.T) { diff --git a/services/agents-api/internal/api/session_agent.go b/services/core/internal/api/session_agent.go similarity index 98% rename from services/agents-api/internal/api/session_agent.go rename to services/core/internal/api/session_agent.go index aa9be76b0..eb7b2ce24 100644 --- a/services/agents-api/internal/api/session_agent.go +++ b/services/core/internal/api/session_agent.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // Resolve wire defaults before admitting the effective execution configuration. diff --git a/services/core/internal/api/session_artifacts.go b/services/core/internal/api/session_artifacts.go new file mode 100644 index 000000000..102f2251f --- /dev/null +++ b/services/core/internal/api/session_artifacts.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "io" + "net/http" + "path" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type SessionArtifactStore interface { + GetSessionArtifact(context.Context, string, string, string) (store.SessionArtifact, error) + ListSessionArtifacts(context.Context, string, string, string, string, int, bool) (store.ArtifactPage, error) + ReadSessionArtifact(context.Context, string, string, string, func(store.SessionArtifact, io.Reader) error) error + DeleteSessionArtifact(context.Context, string, string, string) error +} + +func WithSessionArtifacts(s SessionArtifactStore) Option { + return func(h *Handler) { h.artifacts = s } +} + +func (h *Handler) artifactsReady(w http.ResponseWriter) bool { + if h.artifacts == nil { + writeError(w, http.StatusServiceUnavailable, "artifact_storage_unavailable", "Artifact storage is unavailable.") + return false + } + return true +} + +// @Summary List immutable Session artifacts +// @Description Lists published outputs independently of Environment availability. Sorting uses publication time and ID. A later Turn publishes a path again only when it is new, its bytes changed, or no Artifact remains for it. A malformed environment_id matches nothing. An after value that is not an Artifact of this Session, including a malformed one, returns 400 invalid_request_error with the message "after is not a valid artifact ID". The local default page size is 20; exact upstream defaults remain unverified. +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param environment_id query string false "Producing Environment ID; an unknown or malformed ID returns an empty page" +// @Param after query string false "Last immutable artifact ID" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Publication order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SessionArtifactList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts [get] +func (h *Handler) listSessionArtifacts(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w) { + return + } + options, ok := readPage(w, r, "environment_id") + if !ok { + return + } + page, err := h.artifacts.ListSessionArtifacts(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), r.URL.Query().Get("environment_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + data := make([]v1.SessionArtifact, 0, len(page.Artifacts)) + for _, artifact := range page.Artifacts { + data = append(data, artifactResponse(artifact)) + } + first, last := listBounds(data, func(value v1.SessionArtifact) string { return value.ID }) + writeJSON(w, http.StatusOK, v1.SessionArtifactList{Object: "list", Data: data, HasMore: page.NextCursor != "", FirstID: first, LastID: last}) +} + +// @Summary Retrieve immutable artifact metadata +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {object} v1.SessionArtifact +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [get] +func (h *Handler) getSessionArtifact(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w) { + return + } + artifact, err := h.artifacts.GetSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, artifactResponse(artifact)) +} + +// @Summary Delete a published artifact +// @Description Deletes the published copy without modifying its original workspace file. Already admitted content reads may finish; later reads reject. +// @Tags Artifacts +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {object} v1.SessionArtifactDeleted +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id} [delete] +func (h *Handler) deleteSessionArtifact(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w) { + return + } + id := chi.URLParam(r, "artifact_id") + if err := h.artifacts.DeleteSessionArtifact(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SessionArtifactDeleted{ID: id, Object: "agent.session.artifact.deleted", Deleted: true}) +} + +// @Summary Download immutable artifact bytes +// @Description Streams stored bytes after tenant and Session authorization, including after Environment expiration. Exact upstream headers and Range behavior remain unverified. +// @Tags Artifacts +// @Produce octet-stream +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param artifact_id path string true "Artifact ID" +// @Success 200 {file} binary +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/artifacts/{artifact_id}/content [get] +func (h *Handler) sessionArtifactContent(w http.ResponseWriter, r *http.Request) { + if !h.artifactsReady(w) { + return + } + serveStoredContent(w, r, func(ctx context.Context, consume func(string, int64, io.Reader) error) error { + return h.artifacts.ReadSessionArtifact(ctx, tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "artifact_id"), func(a store.SessionArtifact, body io.Reader) error { + return consume(path.Base(a.Path), a.SizeBytes, body) + }) + }) +} + +func artifactResponse(a store.SessionArtifact) v1.SessionArtifact { + return v1.SessionArtifact{ID: a.ID, CreatedAt: a.CreatedAt.Unix(), EnvironmentID: a.EnvironmentID, + Object: "agent.session.artifact", Path: a.Path, SessionID: a.SessionID, SizeBytes: a.SizeBytes, TurnID: a.TurnID} +} diff --git a/services/core/internal/api/session_artifacts_test.go b/services/core/internal/api/session_artifacts_test.go new file mode 100644 index 000000000..4450ec585 --- /dev/null +++ b/services/core/internal/api/session_artifacts_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type artifactFixture struct { + artifact store.SessionArtifact + tenant, session, id, environment, cursor string + limit int + ascending bool + empty bool + err error + calls int +} + +func (f *artifactFixture) GetSessionArtifact(_ context.Context, tenant, session, id string) (store.SessionArtifact, error) { + f.calls++ + f.tenant, f.session, f.id = tenant, session, id + return f.artifact, f.err +} + +func (f *artifactFixture) ListSessionArtifacts(_ context.Context, tenant, session, environment, cursor string, limit int, ascending bool) (store.ArtifactPage, error) { + f.calls++ + f.tenant, f.session, f.environment, f.cursor, f.limit, f.ascending = tenant, session, environment, cursor, limit, ascending + if f.empty { + return store.ArtifactPage{Artifacts: []store.SessionArtifact{}}, f.err + } + return store.ArtifactPage{Artifacts: []store.SessionArtifact{f.artifact}, NextCursor: f.artifact.ID}, f.err +} + +func (f *artifactFixture) ReadSessionArtifact(ctx context.Context, tenant, session, id string, consume func(store.SessionArtifact, io.Reader) error) error { + a, err := f.GetSessionArtifact(ctx, tenant, session, id) + if err != nil { + return err + } + return consume(a, bytes.NewReader([]byte{0, 255, 1})) +} + +func (f *artifactFixture) DeleteSessionArtifact(ctx context.Context, tenant, session, id string) error { + _, err := f.GetSessionArtifact(ctx, tenant, session, id) + return err +} + +type artifactResponseRecorder struct{ *httptest.ResponseRecorder } + +func (*artifactResponseRecorder) SetWriteDeadline(time.Time) error { return nil } + +func TestSessionArtifactRoutesAndPublicProjection(t *testing.T) { + f := &artifactFixture{artifact: store.SessionArtifact{ID: "artifact", SessionID: "session", EnvironmentID: "environment", TurnID: "turn", Path: "/workspace/outputs/a.bin", SizeBytes: 3, CreatedAt: time.Unix(123, 456)}} + h, _, tenant := testHandler(t, WithSessionArtifacts(f)) + request := func(method, suffix, beta string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/v1/agents/sessions/session/artifacts"+suffix, nil) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", beta) + r.Header.Set("X-Tenant-ID", "untrusted") + w := &artifactResponseRecorder{httptest.NewRecorder()} + h.ServeHTTP(w, r) + return w.ResponseRecorder + } + w := request("GET", "/artifact", "agents=v1") + var got v1.SessionArtifact + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Object != "agent.session.artifact" || got.CreatedAt != 123 || got.SizeBytes != 3 || got.Path != f.artifact.Path || got.TurnID != "turn" || got.EnvironmentID != "environment" || got.SessionID != "session" || got.ID != "artifact" { + t.Fatalf("metadata projection: %d %s", w.Code, w.Body) + } + if f.tenant != tenant || f.session != "session" || f.id != "artifact" { + t.Fatalf("untrusted request scope: %+v", f) + } + w = request("GET", "?environment_id=environment&after=previous&limit=1&order=asc", "agents=v1") + var page v1.SessionArtifactList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || !page.HasMore || len(page.Data) != 1 || f.environment != "environment" || f.cursor != "previous" || f.limit != 1 || !f.ascending { + t.Fatalf("filtered page: %d %s %+v", w.Code, w.Body, f) + } + // The list envelope matches the Session, Turn and Item lists (HE-53). + if page.Object != "list" || page.FirstID == nil || *page.FirstID != "artifact" || page.LastID == nil || *page.LastID != "artifact" { + t.Fatalf("list envelope: %s", w.Body) + } + f.empty = true + if w := request("GET", "?environment_id=not-a-uuid", "agents=v1"); w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` || f.environment != "not-a-uuid" { + t.Fatalf("empty list envelope: %d %s", w.Code, w.Body) + } + f.empty = false + w = request("GET", "/artifact/content", "agents=v1") + if w.Code != 200 || !bytes.Equal(w.Body.Bytes(), []byte{0, 255, 1}) || w.Header().Get("Content-Type") != "application/octet-stream" || w.Header().Get("Content-Length") != "3" { + t.Fatalf("content: %d %s %v", w.Code, w.Body, w.Header()) + } + w = request("DELETE", "/artifact", "agents=v1") + var deleted v1.SessionArtifactDeleted + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &deleted) != nil || !deleted.Deleted || deleted.ID != "artifact" || deleted.Object != "agent.session.artifact.deleted" { + t.Fatalf("delete: %d %s", w.Code, w.Body) + } + for _, suffix := range []string{"?limit=0", "?limit=101", "?limit=-1", "?order=random", "?environment_id=a&environment_id=b", "?limit=1&limit=2"} { + before := f.calls + if w := request("GET", suffix, "agents=v1"); w.Code != 400 || f.calls != before || !strings.Contains(w.Body.String(), `"code":"invalid_request_error"`) { + t.Fatalf("invalid query reached storage: %s %d", suffix, w.Code) + } + } + for _, suffix := range []string{"?unknown=x&tenant_id=other", "/artifact?unknown=x", "/artifact/content?unknown=x"} { + before := f.calls + if w := request("GET", suffix, "agents=v1"); w.Code != 200 || f.calls != before+1 || f.tenant != tenant { + t.Fatalf("unknown query was not ignored: %s %d", suffix, w.Code) + } + } + for _, route := range []struct{ method, suffix string }{{"GET", ""}, {"GET", "/artifact"}, {"GET", "/artifact/content"}, {"DELETE", "/artifact"}} { + before := f.calls + if w := request(route.method, route.suffix, ""); w.Code != 400 || f.calls != before { + t.Fatalf("missing Beta accepted: %s %s %d", route.method, route.suffix, w.Code) + } + f.err = store.ErrNotFound + if w := request(route.method, route.suffix, "agents=v1"); w.Code != 404 { + t.Fatalf("not-found mapping: %s %s %d", route.method, route.suffix, w.Code) + } + } + if w := request(http.MethodPost, "", "agents=v1"); w.Code != 405 { + t.Fatalf("invented create route: %d", w.Code) + } +} diff --git a/services/core/internal/api/session_creation_identity.go b/services/core/internal/api/session_creation_identity.go new file mode 100644 index 000000000..a273a0c32 --- /dev/null +++ b/services/core/internal/api/session_creation_identity.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// sessionCreationRequest records caller intent before mutable sources resolve: +// saved Agents, templates, credentials and hosted deployment defaults. A retry +// then returns the committed Session even after those sources change. Other +// inline requests keep the resolved-request retry rule; the store leaves the +// deployment default out of that hash, so it cannot change their identity. +func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { + if input.Agent != nil && input.Agent.Model != nil && (input.Environment == nil || input.Environment.Type != "openai_hosted") && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + return nil, nil + } + agentID := "" + if input.AgentID != nil { + agentID = *input.AgentID + } + var environment any = input.Environment + if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() || (input.XAgentsCore != nil && len(input.XAgentsCore.Environment) > 0) { + environment = input.originalEnvironment + if len(input.originalEnvironment) == 0 { + environment = input.templateEnvironment + } + } + return json.Marshal(struct { + Execution *v1.SessionExecutionInput `json:"x_agents_core,omitempty"` + AgentID string `json:"agent_id"` + Agent map[string]json.RawMessage `json:"agent,omitempty"` + Environment any `json:"environment"` + Metadata map[string]string `json:"metadata,omitempty"` + VaultIDs []string `json:"vault_ids,omitempty"` + InitialInputs []store.Input `json:"initial_inputs,omitempty"` + }{input.XAgentsCore, agentID, input.agentFields, environment, input.Metadata, input.VaultIDs, initial}) +} + +func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, key string, request json.RawMessage, stream bool) bool { + if len(request) == 0 { + return false + } + result, err := h.store.FindSessionCreation(r.Context(), tenantID(r), key, request, sessionCreator(r)) + if errors.Is(err, store.ErrNotFound) { + return false + } + if err != nil { + writeStoreError(w, r, err) + return true + } + if stream { + events, ok := h.store.(eventStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") + return true + } + if !h.auditSessionOperation(w, r, result.Session.ID, "create") { + return true + } + // Recorded-intent lookup finds an existing creation, which sends no events. + h.respondSessionCreationStream(w, r, events, nil, result) + } else { + session, err := h.store.GetSession(r.Context(), tenantID(r), result.Session.ID) + if err != nil { + writeStoreError(w, r, err) + } else if h.auditSessionOperation(w, r, session.ID, "create") { + h.respondSessionStatus(w, r, session, http.StatusCreated) + } + } + return true +} diff --git a/services/agents-api/internal/api/session_creation_stream.go b/services/core/internal/api/session_creation_stream.go similarity index 96% rename from services/agents-api/internal/api/session_creation_stream.go rename to services/core/internal/api/session_creation_stream.go index d98843852..7658eee27 100644 --- a/services/agents-api/internal/api/session_creation_stream.go +++ b/services/core/internal/api/session_creation_stream.go @@ -5,8 +5,8 @@ import ( "encoding/json" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_creation_stream_test.go b/services/core/internal/api/session_creation_stream_test.go similarity index 99% rename from services/agents-api/internal/api/session_creation_stream_test.go rename to services/core/internal/api/session_creation_stream_test.go index fc34143c3..b5f9d9d29 100644 --- a/services/agents-api/internal/api/session_creation_stream_test.go +++ b/services/core/internal/api/session_creation_stream_test.go @@ -13,10 +13,10 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_credentials.go b/services/core/internal/api/session_credentials.go similarity index 96% rename from services/agents-api/internal/api/session_credentials.go rename to services/core/internal/api/session_credentials.go index 3ac958d6d..80c33036f 100644 --- a/services/agents-api/internal/api/session_credentials.go +++ b/services/core/internal/api/session_credentials.go @@ -5,8 +5,8 @@ import ( "context" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/core/internal/api/session_credentials_test.go similarity index 98% rename from services/agents-api/internal/api/session_credentials_test.go rename to services/core/internal/api/session_credentials_test.go index 07b1a11e3..ff4107727 100644 --- a/services/agents-api/internal/api/session_credentials_test.go +++ b/services/core/internal/api/session_credentials_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/api/session_deletion.go b/services/core/internal/api/session_deletion.go new file mode 100644 index 000000000..ce0dc4f44 --- /dev/null +++ b/services/core/internal/api/session_deletion.go @@ -0,0 +1,43 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// @Summary Delete an execution Session +// @Description Removes a durably idle or failed Session and its history from the public API. A Session whose root Turn is queued, in progress or waiting (including required actions) or whose input reservation is pending returns 409 conflict_error and is left unchanged; cancel it and wait until it is idle before deleting. Subagent child Turns and pending Environment file writes are not checked and do not block deletion. Repeating the deletion of the caller's own deleted Session returns the same confirmation; missing and foreign Sessions return 404. Internal records and native history are retained pending separate physical cleanup; overlapping stream timing remains unverified. +// @Tags Sessions +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Success 200 {object} v1.SessionDeleted +// @Failure 400,401,404,409,413,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id} [delete] +func (h *Handler) deleteSession(w http.ResponseWriter, r *http.Request) { + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Session deletion does not accept a request body.") + return + } + id := chi.URLParam(r, "session_id") + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil { + writeStoreError(w, r, store.ErrNotFound) + return + } + if err := h.store.DeleteSession(r.Context(), tenantID(r), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SessionDeleted{ID: parsed.String(), Object: "agent.session.deleted", Deleted: true}) +} diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go new file mode 100644 index 000000000..be6a256f5 --- /dev/null +++ b/services/core/internal/api/session_diagnostics.go @@ -0,0 +1,177 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type DiagnosticFailure struct { + Code string `json:"code" enums:"harness_error,model_provider_required,runtime_unavailable,runtime_disconnected,runtime_preparation_failed,execution_interrupted,delivery_unconfirmed,input_rejected,executor_protocol_error,core_storage_failed,internal_error,environment_connection_timeout,environment_unavailable,environment_provisioning_failed,authentication_error,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,invalid_request,resource_not_found,request_timeout,context_length_exceeded,cyber_policy,connection_failed"` + Params CoreErrorDetails `json:"params" swaggertype:"object"` + FailedAt *time.Time `json:"failed_at" extensions:"x-nullable"` +} + +type SessionDiagnosticFailure struct { + DiagnosticFailure + Source string `json:"source" enums:"turn,environment,environment_input"` + TurnID string `json:"turn_id,omitempty"` +} + +type SessionDiagnostics struct { + Object string `json:"object" enums:"core.session_diagnostics"` + SessionID string `json:"session_id"` + Status string `json:"status" enums:"idle,in_progress,requires_action,failed"` + Failure *SessionDiagnosticFailure `json:"failure" extensions:"x-nullable"` +} + +type ItemDiagnosticTiming struct { + ItemID string `json:"item_id"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at" extensions:"x-nullable"` + ObservedDurationMS *int64 `json:"observed_duration_ms" extensions:"x-nullable"` +} + +type TurnDiagnostics struct { + Object string `json:"object" enums:"core.turn_diagnostics"` + SessionID string `json:"session_id"` + TurnID string `json:"turn_id"` + Status string `json:"status"` + Failure *DiagnosticFailure `json:"failure" extensions:"x-nullable"` + Items []ItemDiagnosticTiming `json:"items"` + ItemsTruncated bool `json:"items_truncated"` +} + +type sessionDiagnosticsStore interface { + GetSessionDiagnosticsSnapshot(context.Context, string, string) (store.Session, error) + GetTurnDiagnosticsSnapshot(context.Context, string, string, string) (store.TurnDiagnosticsSnapshot, error) +} + +// @Summary Retrieve root Session diagnostics +// @Description Core key only. Safe failure categories from one committed snapshot; no native text or historical inference. +// @Tags Sessions +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param session_id path string true "Session ID" +// @Success 200 {object} SessionDiagnostics +// @Failure 400,401,404,500,503 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/diagnostics [get] +func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request) { + source, ok := h.store.(sessionDiagnosticsStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Session diagnostics are unavailable.") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + session, err := source.GetSessionDiagnosticsSnapshot(ctx, tenantID(r), chi.URLParam(r, "session_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + public, err := sessionResponse(session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + response := SessionDiagnostics{Object: "core.session_diagnostics", SessionID: public.ID, Status: public.Status} + if public.Status == "failed" { + failure := SessionDiagnosticFailure{DiagnosticFailure: DiagnosticFailure{Code: "internal_error", Params: CoreErrorDetails{}}} + switch { + case session.EnvironmentFailure != nil: + failure.Source = "environment" + failure.Code = "environment_provisioning_failed" + failure.FailedAt = diagnosticTime(session.EnvironmentFailure.FailedAt) + failure.Params = provisioningFailureParams(session.EnvironmentFailure.Detail) + case session.EnvironmentInputActivity != nil: + failure.Source = "environment_input" + failure.FailedAt = diagnosticTime(session.EnvironmentInputActivity.LastActiveAt) + switch session.EnvironmentInputActivity.Failure { + case "": + failure.Code = "environment_connection_timeout" + case "environment_unavailable": + failure.Code = "environment_unavailable" + case "runtime_preparation_failed": + failure.Code = "runtime_preparation_failed" + case "model_provider_required": + failure.Code = "model_provider_required" + } + case session.LastTurn != nil: + failure.Source, failure.TurnID = "turn", session.LastTurn.ID + failure.DiagnosticFailure = *turnDiagnosticFailure(*session.LastTurn) + } + response.Failure = &failure + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +// @Summary Retrieve root Turn diagnostics +// @Description Core key only. At most 1000 root Item receipt timings in public Item order. Receipt intervals are not native execution durations. +// @Tags Turns +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param session_id path string true "Session ID" +// @Param turn_id path string true "Root Turn ID" +// @Success 200 {object} TurnDiagnostics +// @Failure 400,401,404,500,503 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics [get] +func (h *Handler) getTurnDiagnostics(w http.ResponseWriter, r *http.Request) { + source, ok := h.store.(sessionDiagnosticsStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Session diagnostics are unavailable.") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + snapshot, err := source.GetTurnDiagnosticsSnapshot(ctx, tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "turn_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + public, err := turnResponse(snapshot.Session, snapshot.Turn) + if err != nil { + writeStoreError(w, r, err) + return + } + response := TurnDiagnostics{Object: "core.turn_diagnostics", SessionID: public.SessionID, TurnID: public.ID, Status: public.Status, Failure: turnDiagnosticFailure(snapshot.Turn), Items: []ItemDiagnosticTiming{}, ItemsTruncated: snapshot.ItemsTruncated} + for _, value := range snapshot.Items { + item := ItemDiagnosticTiming{ItemID: value.ItemID, StartedAt: value.StartedAt, CompletedAt: value.CompletedAt} + if value.CompletedAt != nil { + duration := value.CompletedAt.Sub(value.StartedAt).Milliseconds() + item.ObservedDurationMS = &duration + } + response.Items = append(response.Items, item) + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func diagnosticTime(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + return &value +} + +func provisioningFailureParams(detail *store.ProvisioningFailureDetail) CoreErrorDetails { + params := CoreErrorDetails{"step": CoreErrorNull(), "index": CoreErrorNull(), "exit_code": CoreErrorNull()} + if detail == nil { + return params + } + if detail.Step != nil { + params["step"] = CoreErrorString(*detail.Step) + } + if detail.Index != nil { + params["index"] = CoreErrorNumber(float64(*detail.Index)) + } + if detail.ExitCode != nil { + params["exit_code"] = CoreErrorNumber(float64(*detail.ExitCode)) + } + return params +} diff --git a/services/agents-api/internal/api/session_diagnostics_deadline_test.go b/services/core/internal/api/session_diagnostics_deadline_test.go similarity index 97% rename from services/agents-api/internal/api/session_diagnostics_deadline_test.go rename to services/core/internal/api/session_diagnostics_deadline_test.go index 034aae2e9..ee058279b 100644 --- a/services/agents-api/internal/api/session_diagnostics_deadline_test.go +++ b/services/core/internal/api/session_diagnostics_deadline_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_diagnostics_public_compat_test.go b/services/core/internal/api/session_diagnostics_public_compat_test.go similarity index 94% rename from services/agents-api/internal/api/session_diagnostics_public_compat_test.go rename to services/core/internal/api/session_diagnostics_public_compat_test.go index fd0a46825..d01d2c7d7 100644 --- a/services/agents-api/internal/api/session_diagnostics_public_compat_test.go +++ b/services/core/internal/api/session_diagnostics_public_compat_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go new file mode 100644 index 000000000..6fc5c9a20 --- /dev/null +++ b/services/core/internal/api/session_diagnostics_test.go @@ -0,0 +1,118 @@ +package api + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestDiagnosticsCoreHandlerDatabaseBoundary(t *testing.T) { + s, pool := diagnosticDatabase(t) + h, _, tenant := adminTestHandler(t, func(h *Handler) { h.store = s }) + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: identity.Subject{Kind: "service_account", ID: "diagnostic-test"}, Engine: "codex", IdempotencyKey: "diagnostics", Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`)}) + if err != nil { + t.Fatal(err) + } + receipt, err := s.SubmitMessage(t.Context(), tenant, session.ID, "input", json.RawMessage(`{"text":"input-secret-canary"}`)) + if err != nil { + t.Fatal(err) + } + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + if _, err = s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"device_disconnected","error":"Bearer raw-secret-canary https://private.example/key","done":{"native_id":"secret-native-canary"}}`)}); err != nil { + t.Fatal(err) + } + base := adminSessionsPath + session.ID + for _, path := range []string{base + "/diagnostics", base + "/turns/" + receipt.TurnID + "/diagnostics"} { + if w := diagnosticRequest(h, path, ""); w.Code != 401 { + t.Fatal("unauthed diagnostics", w.Code, w.Body) + } + if w := diagnosticRequest(h, path, "Bearer caller"); w.Code != 401 { + t.Fatal("Project credential entered Core route", w.Code, w.Body) + } + w := diagnosticRequest(h, path, "Bearer admin") + if w.Code != 200 || w.Header().Get("Cache-Control") != "no-store" || !strings.Contains(w.Body.String(), `"code":"runtime_disconnected"`) || strings.Contains(w.Body.String(), "canary") || strings.Contains(w.Body.String(), "private.example") { + t.Fatal(w.Code, w.Body) + } + } + missing := diagnosticRequest(h, base+"/turns/"+uuid.NewString()+"/diagnostics", "Bearer admin") + malformed := diagnosticRequest(h, base+"/turns/malformed/diagnostics", "Bearer admin") + if missing.Code != 404 || malformed.Code != 404 || missing.Body.String() != malformed.Body.String() { + t.Fatal("missing path semantics changed", missing.Body, malformed.Body) + } + foreign := diagnosticRequest(h, strings.Replace(base, managementProjectID, uuid.NewString(), 1)+"/diagnostics", "Bearer admin") + if foreign.Code != 404 { + t.Fatal("foreign project visible", foreign.Code) + } + if _, err = pool.Exec(t.Context(), "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID); err != nil { + t.Fatal(err) + } + for _, path := range []string{base + "/diagnostics", base + "/turns/" + receipt.TurnID + "/diagnostics"} { + if w := diagnosticRequest(h, path, "Bearer admin"); w.Code != 404 { + t.Fatal("deleted root visible", w.Code, w.Body) + } + } +} + +type diagnosticSnapshotStore struct { + ResourceStore + session store.Session +} + +func (s diagnosticSnapshotStore) GetSessionDiagnosticsSnapshot(context.Context, string, string) (store.Session, error) { + return s.session, nil +} +func (s diagnosticSnapshotStore) GetTurnDiagnosticsSnapshot(context.Context, string, string, string) (store.TurnDiagnosticsSnapshot, error) { + return store.TurnDiagnosticsSnapshot{Session: s.session, Turn: *s.session.LastTurn, Items: []store.ItemDiagnosticTiming{}}, nil +} + +func TestDiagnosticsFailurePrecedenceAndUnknownTime(t *testing.T) { + id, turnID := uuid.NewString(), uuid.NewString() + base := store.Session{ID: id, Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test"},"environment":{"type":"none"}}`), LastTurn: &store.Turn{ID: turnID, SessionID: id, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed","error":"secret-canary"}`)}} + for _, tc := range []struct { + activity *store.EnvironmentInputActivity + code, source string + }{{nil, "harness_error", "turn"}, {&store.EnvironmentInputActivity{Status: "failed"}, "environment_connection_timeout", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "model_provider_required"}, "model_provider_required", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "runtime_preparation_failed"}, "runtime_preparation_failed", "environment_input"}, {&store.EnvironmentInputActivity{Status: "failed", Failure: "secret-canary"}, "internal_error", "environment_input"}} { + value := base + value.EnvironmentInputActivity = tc.activity + h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: value} }) + w := diagnosticRequest(h, adminSessionsPath+id+"/diagnostics", "Bearer admin") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":"`+tc.code+`"`) || !strings.Contains(w.Body.String(), `"source":"`+tc.source+`"`) || !strings.Contains(w.Body.String(), `"failed_at":null`) || strings.Contains(w.Body.String(), "canary") { + t.Fatal(w.Code, w.Body) + } + } + base.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "idle", LastActiveAt: time.Now()} + h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: base} }) + if w := diagnosticRequest(h, adminSessionsPath+id+"/diagnostics", "Bearer admin"); w.Code != 200 || !strings.Contains(w.Body.String(), `"failure":null`) { + t.Fatal("public activity precedence changed", w.Code, w.Body) + } +} + +func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T) { + session := hostedFailureSession() + session.EnvironmentInputActivity = &store.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()} + session.LastTurn = &store.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)} + step, index, exit := "setup", 2, 7 + for _, detail := range []*store.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}} { + session.EnvironmentFailure = &store.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail} + h, _, _ := adminTestHandler(t, func(h *Handler) { h.store = diagnosticSnapshotStore{session: session} }) + w := diagnosticRequest(h, adminSessionsPath+session.ID+"/diagnostics", "Bearer admin") + if w.Code != 200 || strings.Contains(w.Body.String(), "canary") || !strings.Contains(w.Body.String(), `"code":"environment_provisioning_failed"`) || !strings.Contains(w.Body.String(), `"source":"environment"`) { + t.Fatal(w.Code, w.Body) + } + want := `"params":{"exit_code":null,"index":null,"step":null}` + if detail != nil { + want = `"params":{"exit_code":7,"index":2,"step":"setup"}` + } + if !strings.Contains(w.Body.String(), want) || !strings.Contains(w.Body.String(), `"failed_at":null`) { + t.Fatal("historical reason parsed or time invented", w.Body) + } + } +} diff --git a/services/agents-api/internal/api/session_environment_http_test.go b/services/core/internal/api/session_environment_http_test.go similarity index 95% rename from services/agents-api/internal/api/session_environment_http_test.go rename to services/core/internal/api/session_environment_http_test.go index c7edb366a..44f064679 100644 --- a/services/agents-api/internal/api/session_environment_http_test.go +++ b/services/core/internal/api/session_environment_http_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_environment_test.go b/services/core/internal/api/session_environment_test.go similarity index 97% rename from services/agents-api/internal/api/session_environment_test.go rename to services/core/internal/api/session_environment_test.go index 9ceb008ae..9b7cee0e2 100644 --- a/services/agents-api/internal/api/session_environment_test.go +++ b/services/core/internal/api/session_environment_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const environmentOrigin = "wss://core.example/api/v1/agent-daemon/ws" diff --git a/services/core/internal/api/session_execution_configuration.go b/services/core/internal/api/session_execution_configuration.go new file mode 100644 index 000000000..ffd2bf81f --- /dev/null +++ b/services/core/internal/api/session_execution_configuration.go @@ -0,0 +1,84 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +type sessionExecutionConfigurationStore interface { + GetSessionExecutionConfiguration(context.Context, string, string) (v1.SessionExecutionConfiguration, error) +} + +// Record selection sources at resolution time. Null Agent extensions reset the +// harness to deployment defaults but do not clear inherited provider bundles. +func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inherited, provider *v1.ModelProviderInput, engine string, raw json.RawMessage) v1.SessionExecutionConfiguration { + var configuration struct { + Agent struct { + Model string `json:"model"` + Core *v1.AgentsCore `json:"x_agents_core"` + } `json:"agent"` + } + _ = json.Unmarshal(raw, &configuration) // The resolved configuration was already validated. + modelSource := "session" + if saved != nil && (input.Agent == nil || input.Agent.Model == nil) { + modelSource = "agent" + } + if input.modelSource != "" { + modelSource = input.modelSource + } + harnessSource := "deployment" + if _, overridden := input.agentFields["x_agents_core"]; overridden { + if input.Agent != nil && input.Agent.XAgentsCore != nil && input.Agent.XAgentsCore.Harness != "" { + harnessSource = "session" + } else if input.Agent != nil && input.Agent.XAgentsCore != nil && saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { + harnessSource = "agent" + } + } else if saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { + harnessSource = "agent" + } + selection := v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + if provider != nil { + // Deployment defaults are readable with the same Core key, so new + // Sessions record their safe view too; historical rows stay redacted. + selection.Source, selection.Status, selection.Configuration = "deployment", "available", provider.SafeView() + if input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { + selection.Source = "session" + } else if inherited != nil { + selection.Source = "agent" + } + } + native := json.RawMessage(`{}`) + if configuration.Agent.Core != nil { + native = v1.ResolvedHarnessConfig(configuration.Agent.Core.HarnessConfig) + } + nativeSource := input.harnessConfigSource + if nativeSource == "" { + nativeSource = "unknown" + } + return v1.SessionExecutionConfiguration{ + HarnessConfig: v1.ExecutionHarnessConfigSelection{Value: native, Source: nativeSource}, + Object: "agent.session.execution_configuration", SchemaVersion: 1, + Model: v1.ExecutionSelection{Value: &configuration.Agent.Model, Source: modelSource}, + Harness: v1.ExecutionSelection{Value: &engine, Source: harnessSource}, ModelProvider: selection, + } +} + +// getSessionExecutionConfiguration serves the administrator per-Session read. +func (h *Handler) getSessionExecutionConfiguration(w http.ResponseWriter, r *http.Request) { + source, ok := h.store.(sessionExecutionConfigurationStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "execution_configuration_unavailable", "Session execution configuration is unavailable.") + return + } + configuration, err := source.GetSessionExecutionConfiguration(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, configuration) +} diff --git a/services/core/internal/api/session_execution_configuration_test.go b/services/core/internal/api/session_execution_configuration_test.go new file mode 100644 index 000000000..f6148b7d9 --- /dev/null +++ b/services/core/internal/api/session_execution_configuration_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func TestExecutionConfigurationSources(t *testing.T) { + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://saved.example/v1", APIKey: "secret-canary"} + saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "codex", ModelProvider: provider.SafeView()}}} + for _, tc := range []struct { + name, agent, extension string + saved *v1.SavedAgent + inherited, provider *v1.ModelProviderInput + modelSource, harnessSource, providerSource, status string + }{ + {"saved", ``, ``, saved, provider, provider, "agent", "agent", "agent", "available"}, + {"model override", `,"agent":{"model":"override"}`, ``, saved, provider, provider, "session", "agent", "agent", "available"}, + {"harness override", `,"agent":{"x_agents_core":{"harness":"codex"}}`, ``, saved, provider, provider, "agent", "session", "agent", "available"}, + {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "deployment", "agent", "available"}, + {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "agent", "session", "available"}, + {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "available"}, + {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "available"}, + {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, nil, "session", "session", "unknown", "unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"environment":{"type":"openai_hosted"}`+tc.agent+tc.extension+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + p := sessionExecutionProjection(input, tc.saved, tc.inherited, tc.provider, "codex", json.RawMessage(`{"agent":{"model":"resolved"}}`)) + if p.Model.Source != tc.modelSource || p.Harness.Source != tc.harnessSource || p.ModelProvider.Source != tc.providerSource || p.ModelProvider.Status != tc.status { + t.Fatalf("wrong sources: %#v", p) + } + raw, _ := json.Marshal(p) + if strings.Contains(string(raw), "secret-canary") || (tc.status != "available" && p.ModelProvider.Configuration != nil) { + t.Fatal("private provider leaked") + } + }) + } +} + +type executionConfigurationReader struct { + ResourceStore + calls int + tenant, id string + value v1.SessionExecutionConfiguration + err error +} + +func (s *executionConfigurationReader) GetSessionExecutionConfiguration(_ context.Context, tenant, id string) (v1.SessionExecutionConfiguration, error) { + s.calls++ + s.tenant, s.id = tenant, id + return s.value, s.err +} + +func TestExecutionConfigurationReadBoundary(t *testing.T) { + s := &executionConfigurationReader{value: v1.SessionExecutionConfiguration{Object: "agent.session.execution_configuration", SchemaVersion: 1, SessionID: "frozen"}} + h, _, tenant := adminTestHandler(t, func(h *Handler) { h.store = s }) + for _, tc := range []struct { + auth string + err error + status int + }{ + {"", nil, 401}, {"Bearer admin", nil, 200}, {"Bearer admin", store.ErrNotFound, 404}, + } { + s.err = tc.err + before := s.calls + r := httptest.NewRequest(http.MethodGet, adminSessionsPath+"frozen/execution-configuration?ignored=true", nil) + r.Header.Set("Authorization", tc.auth) + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.status { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + if tc.auth == "" { + if s.calls != before { + t.Fatal("unauthorized read reached storage") + } + continue + } + if s.tenant != tenant || s.id != "frozen" { + t.Fatal("wrong tenant/resource") + } + if w.Code == 200 && w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("snapshot response cacheable") + } + } +} diff --git a/services/core/internal/api/session_initial_input.go b/services/core/internal/api/session_initial_input.go new file mode 100644 index 000000000..4320345b6 --- /dev/null +++ b/services/core/internal/api/session_initial_input.go @@ -0,0 +1,32 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func initialSessionInputs(raw json.RawMessage) ([]store.Input, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + if raw[0] == '"' { + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return nil, store.ErrInvalidInput + } + raw, _ = json.Marshal([]v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}) + } + // Preserve the array's original fields for the shared strict message decoder. + event, err := json.Marshal(struct { + Type string `json:"type"` + Input json.RawMessage `json:"input"` + }{Type: "agent.session.input.message", Input: raw}) + if err != nil { + return nil, store.ErrInvalidInput + } + return executionInputs([]json.RawMessage{event}) +} diff --git a/services/agents-api/internal/api/session_initial_input_test.go b/services/core/internal/api/session_initial_input_test.go similarity index 100% rename from services/agents-api/internal/api/session_initial_input_test.go rename to services/core/internal/api/session_initial_input_test.go diff --git a/services/agents-api/internal/api/session_metadata.go b/services/core/internal/api/session_metadata.go similarity index 100% rename from services/agents-api/internal/api/session_metadata.go rename to services/core/internal/api/session_metadata.go diff --git a/services/agents-api/internal/api/session_model_configuration.go b/services/core/internal/api/session_model_configuration.go similarity index 98% rename from services/agents-api/internal/api/session_model_configuration.go rename to services/core/internal/api/session_model_configuration.go index e8e702ca2..ec202b148 100644 --- a/services/agents-api/internal/api/session_model_configuration.go +++ b/services/core/internal/api/session_model_configuration.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // Resolve mutable defaults once, before constructing the immutable Agent. Model diff --git a/services/agents-api/internal/api/session_model_configuration_test.go b/services/core/internal/api/session_model_configuration_test.go similarity index 97% rename from services/agents-api/internal/api/session_model_configuration_test.go rename to services/core/internal/api/session_model_configuration_test.go index 37f99c444..56bd8bfac 100644 --- a/services/agents-api/internal/api/session_model_configuration_test.go +++ b/services/core/internal/api/session_model_configuration_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSessionNativeConfigurationSources(t *testing.T) { diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/core/internal/api/session_model_defaults.go similarity index 97% rename from services/agents-api/internal/api/session_model_defaults.go rename to services/core/internal/api/session_model_defaults.go index 9dd2e510f..e6f6f249a 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/core/internal/api/session_model_defaults.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/session_request.go b/services/core/internal/api/session_request.go similarity index 96% rename from services/agents-api/internal/api/session_request.go rename to services/core/internal/api/session_request.go index 6e07fc59b..de7f14130 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -4,8 +4,8 @@ import ( "bytes" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Keep explicit null until validation for fields whose Go zero values would diff --git a/services/agents-api/internal/api/session_request_test.go b/services/core/internal/api/session_request_test.go similarity index 97% rename from services/agents-api/internal/api/session_request_test.go rename to services/core/internal/api/session_request_test.go index fe2fa633e..2bb367ada 100644 --- a/services/agents-api/internal/api/session_request_test.go +++ b/services/core/internal/api/session_request_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func TestSessionCreateFieldPresence(t *testing.T) { diff --git a/services/agents-api/internal/api/session_response.go b/services/core/internal/api/session_response.go similarity index 97% rename from services/agents-api/internal/api/session_response.go rename to services/core/internal/api/session_response.go index 66c001c64..5bf35c52c 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // WithEnvironmentRemoteURL uses the composition's validated daemon executor URL for self-hosted requests and output. diff --git a/services/agents-api/internal/api/session_semantics_test.go b/services/core/internal/api/session_semantics_test.go similarity index 97% rename from services/agents-api/internal/api/session_semantics_test.go rename to services/core/internal/api/session_semantics_test.go index bb8ffa076..a10063f02 100644 --- a/services/agents-api/internal/api/session_semantics_test.go +++ b/services/core/internal/api/session_semantics_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type emptyEventSessionStore struct { diff --git a/services/agents-api/internal/api/session_template.go b/services/core/internal/api/session_template.go similarity index 96% rename from services/agents-api/internal/api/session_template.go rename to services/core/internal/api/session_template.go index fa15211b8..f7bad3326 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/core/internal/api/session_template.go @@ -7,9 +7,9 @@ import ( "maps" "slices" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Validate the reference and inline shape without looking up mutable resources. diff --git a/services/agents-api/internal/api/session_template_composition_test.go b/services/core/internal/api/session_template_composition_test.go similarity index 98% rename from services/agents-api/internal/api/session_template_composition_test.go rename to services/core/internal/api/session_template_composition_test.go index 57601bdfa..27b85ac88 100644 --- a/services/agents-api/internal/api/session_template_composition_test.go +++ b/services/core/internal/api/session_template_composition_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type compositionTemplateStore struct { diff --git a/services/agents-api/internal/api/session_template_null_test.go b/services/core/internal/api/session_template_null_test.go similarity index 100% rename from services/agents-api/internal/api/session_template_null_test.go rename to services/core/internal/api/session_template_null_test.go diff --git a/services/core/internal/api/session_tools.go b/services/core/internal/api/session_tools.go new file mode 100644 index 000000000..16e9f0b49 --- /dev/null +++ b/services/core/internal/api/session_tools.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { + tools := make([]json.RawMessage, len(input)) + functions := make([]v1.FunctionToolInput, 0, len(input)) + positions := make([]int, 0, len(input)) + servers := map[string]bool{} + search := false + controls := map[string]bool{} + for i, raw := range input { + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &kind) != nil { + return nil, errors.New("Invalid execution tool configuration.") + } + switch kind.Type { + case "programmatic_tool_calling", "web_search": + if controls[kind.Type] { + return nil, errors.New("Execution requires distinct tool controls.") + } + controls[kind.Type] = true + var resolved json.RawMessage + var err error + if kind.Type == "web_search" { + resolved, err = resolveDisabledWebSearch(raw) + } else { + resolved, err = resolveProgrammaticTool(raw) + var value struct { + Enabled bool `json:"enabled"` + } + if err == nil { + _ = json.Unmarshal(resolved, &value) + if value.Enabled { + err = errors.New("Programmatic tool calling is not qualified for execution.") + } + } + } + if err != nil { + return nil, err + } + tools[i] = resolved + case "tool_search": + if search || decodeInputObject(raw, &kind, "type") != nil { + return nil, errors.New("Execution requires one type-only tool_search declaration.") + } + search = true + tools[i], _ = json.Marshal(kind) + case "mcp": + resolved, err := resolveMCPTool(raw, false) + if err != nil { + return nil, err + } + var tool v1.MCPTool + if json.Unmarshal(resolved, &tool) != nil || servers[tool.ServerLabel] { + return nil, errors.New("Execution requires distinct MCP server labels.") + } + servers[tool.ServerLabel] = true + tools[i] = resolved + case "function": + var function v1.FunctionToolInput + if decodeInputObject(raw, &function, "type", "name", "description", "parameters", "defer_loading") != nil { + return nil, errors.New("Invalid execution function fields.") + } + functions = append(functions, function) + positions = append(positions, i) + default: + return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, qualified HTTP MCP and explicit disabled controls.") + } + } + resolved, err := resolveFunctions(functions) + if err != nil { + return nil, err + } + for i, value := range resolved { + tools[positions[i]] = value + } + return tools, nil +} diff --git a/services/core/internal/api/session_write_audit.go b/services/core/internal/api/session_write_audit.go new file mode 100644 index 000000000..632e7ace6 --- /dev/null +++ b/services/core/internal/api/session_write_audit.go @@ -0,0 +1,29 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" +) + +type sessionWriteAuditor interface { + AuditSessionOperation(context.Context, string, string, string) error +} + +func (h *Handler) auditSessionOperation(w http.ResponseWriter, r *http.Request, sessionID, action string) bool { + if _, ok := writeaudit.FromContext(r.Context()); !ok { + return true + } + auditor, ok := h.store.(sessionWriteAuditor) + if !ok { + writeStoreError(w, r, errors.New("session write audit is unavailable")) + return false + } + if err := auditor.AuditSessionOperation(r.Context(), tenantID(r), sessionID, action); err != nil { + writeStoreError(w, r, err) + return false + } + return true +} diff --git a/services/core/internal/api/session_write_audit_test.go b/services/core/internal/api/session_write_audit_test.go new file mode 100644 index 000000000..f48026f87 --- /dev/null +++ b/services/core/internal/api/session_write_audit_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// General wire fixtures do not persist resources; dedicated audit fixtures below +// check the no-op/replay boundary independently of business Store auditing. +func (s *recordingStore) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { + if auditor, ok := s.ResourceStore.(sessionWriteAuditor); ok { + return auditor.AuditSessionOperation(ctx, tenant, session, action) + } + return nil +} + +func (f *streamFixture) AuditSessionOperation(context.Context, string, string, string) error { + return nil +} + +type auditedSessionFixture struct { + streamFixture + err error + actions []string + source writeaudit.Source +} + +func (f *auditedSessionFixture) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { + return store.SessionCreation{Session: f.session}, nil +} + +func (f *auditedSessionFixture) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { + if tenant != f.session.TenantID || session != f.session.ID { + return store.ErrNotFound + } + f.source, _ = writeaudit.FromContext(ctx) + f.actions = append(f.actions, action) + return f.err +} + +func TestSessionAuditOnlyRoutesFailClosed(t *testing.T) { + for _, route := range []string{"empty-events", "creation-replay", "stream-replay"} { + for _, fail := range []bool{false, true} { + t.Run(route+map[bool]string{false: "/commit", true: "/rollback"}[fail], func(t *testing.T) { + tenant, session := uuid.NewString(), uuid.NewString() + f := &auditedSessionFixture{streamFixture: streamFixture{session: store.Session{ID: session, TenantID: tenant, Configuration: json.RawMessage(`{"environment":{"type":"none"},"agent":{"id":"agent","model":"test"}}`)}}} + if fail { + f.err = errors.New("audit unavailable") + } + h := &Handler{store: f} + ctx := context.WithValue(t.Context(), principalContextKey{}, identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant}, SubjectKind: "service_account", SubjectID: "test"}) + ctx = writeaudit.WithSource(ctx, writeaudit.Source{TenantID: tenant, RequestID: "request", TraceID: "trace"}) + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("session_id", session) + ctx = context.WithValue(ctx, chi.RouteCtxKey, routeContext) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"events":[]}`)).WithContext(ctx) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + if route == "empty-events" { + h.createEvents(w, r) + } else if !h.recoverSessionCreation(w, r, "key", json.RawMessage(`{}`), route == "stream-replay") { + t.Fatal("replay not handled") + } + wantStatus, wantAction := 201, "create" + if route == "empty-events" { + wantStatus, wantAction = 202, "send_events" + } + if route == "stream-replay" { + wantStatus = 201 + } + if fail { + wantStatus = 500 + } + if w.Code != wantStatus || len(f.actions) != 1 || f.actions[0] != wantAction || f.source.RequestID != "request" { + t.Fatal(w.Code, w.Body.String(), f.actions, f.source) + } + }) + } + } +} diff --git a/services/core/internal/api/skills.go b/services/core/internal/api/skills.go new file mode 100644 index 000000000..38a7972ad --- /dev/null +++ b/services/core/internal/api/skills.go @@ -0,0 +1,170 @@ +package api + +import ( + "context" + "net/http" + "strconv" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type SkillStore interface { + CreateSkill(context.Context, string, []byte) (store.Skill, error) + GetSkill(context.Context, string, string) (store.Skill, error) + UpdateSkillDefault(context.Context, string, string, string) (store.Skill, error) + DeleteSkill(context.Context, string, string) error + ListSkills(context.Context, string, string, int, bool) (store.SkillPage, error) + CreateSkillVersion(context.Context, string, string, []byte, bool) (store.SkillVersion, error) + GetSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) + ReadSkillVersion(context.Context, string, string, string) (store.SkillVersion, []byte, error) + ReadDefaultSkillVersion(context.Context, string, string) (store.SkillVersion, []byte, error) + DeleteSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) + ListSkillVersions(context.Context, string, string, string, int, bool) (store.SkillVersionPage, error) +} + +func WithSkills(s SkillStore) Option { return func(h *Handler) { h.skills = s } } + +func (h *Handler) registerSkillRoutes(r chi.Router) { + r.Post("/v1/skills", h.createSkill) + r.Get("/v1/skills", h.listSkills) + r.Get("/v1/skills/{skill_id}", h.getSkill) + r.Post("/v1/skills/{skill_id}", h.updateSkill) + r.Delete("/v1/skills/{skill_id}", h.deleteSkill) + r.Get("/v1/skills/{skill_id}/content", h.skillContent) + r.Head("/v1/skills/{skill_id}/content", methodNotAllowed) + r.Post("/v1/skills/{skill_id}/versions", h.createSkillVersion) + r.Get("/v1/skills/{skill_id}/versions", h.listSkillVersions) + r.Get("/v1/skills/{skill_id}/versions/{version}", h.getSkillVersion) + r.Delete("/v1/skills/{skill_id}/versions/{version}", h.deleteSkillVersion) + r.Get("/v1/skills/{skill_id}/versions/{version}/content", h.skillVersionContent) + r.Head("/v1/skills/{skill_id}/versions/{version}/content", methodNotAllowed) +} + +func (h *Handler) skillsReady(w http.ResponseWriter) bool { + if h.skills == nil { + writeError(w, http.StatusServiceUnavailable, "skill_storage_unavailable", "Skill storage is unavailable.") + return false + } + return true +} + +// @Summary Retrieve Skill metadata +// @Description Returns tenant-owned metadata without decrypting contents or starting Runtime. No Beta header is required. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Success 200 {object} v1.Skill +// @Router /skills/{skill_id} [get] +func (h *Handler) getSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + value, err := h.skills.GetSkill(r.Context(), tenantID(r), chi.URLParam(r, "skill_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillResponseResource(value)) +} + +// @Summary Update the default Skill version +// @Description Changes only the tenant-owned default pointer; immutable versions and existing Session snapshots remain unchanged. +// @Tags Skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param body body v1.SkillUpdateRequest true "Default version" +// @Success 200 {object} v1.Skill +// @Router /skills/{skill_id} [post] +func (h *Handler) updateSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + body, ok := readJSONBodyLimit(w, r, 64<<10, "Request exceeds 64 KiB.") + if !ok { + return + } + var input v1.SkillUpdateRequest + if decodeInputObject(body, &input, "default_version") != nil || input.DefaultVersion == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + value, err := h.skills.UpdateSkillDefault(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), input.DefaultVersion) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillResponseResource(value)) +} + +// @Summary Delete a Skill and its versions +// @Description Deletes tenant-owned source bundles. Existing Session installation snapshots remain independent. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Success 200 {object} v1.SkillDeleted +// @Router /skills/{skill_id} [delete] +func (h *Handler) deleteSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + id := chi.URLParam(r, "skill_id") + if err := h.skills.DeleteSkill(r.Context(), tenantID(r), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SkillDeleted{ID: id, Object: "skill.deleted", Deleted: true}) +} + +// @Summary Retrieve Skill version metadata +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param version path string true "Concrete version number" +// @Success 200 {object} v1.SkillVersion +// @Router /skills/{skill_id}/versions/{version} [get] +func (h *Handler) getSkillVersion(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + value, err := h.skills.GetSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillVersionResponse(value)) +} + +// @Summary Delete a Skill version +// @Description Deleting the only remaining version also deletes the Skill; existing Session installation snapshots remain independent. The default version cannot be deleted while other versions remain. Version numbers are never reused. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param version path string true "Concrete version number" +// @Success 200 {object} v1.SkillVersionDeleted +// @Router /skills/{skill_id}/versions/{version} [delete] +func (h *Handler) deleteSkillVersion(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + value, err := h.skills.DeleteSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SkillVersionDeleted{ID: value.ID, Object: "skill.version.deleted", Version: strconv.FormatInt(value.Version, 10), Deleted: true}) +} + +func skillResponseResource(s store.Skill) v1.Skill { + return v1.Skill{ID: s.ID, Object: "skill", CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, DefaultVersion: strconv.FormatInt(s.DefaultVersion, 10), LatestVersion: strconv.FormatInt(s.LatestVersion, 10)} +} +func skillVersionResponse(s store.SkillVersion) v1.SkillVersion { + return v1.SkillVersion{ID: s.ID, Object: "skill.version", SkillID: s.SkillID, CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, Version: strconv.FormatInt(s.Version, 10)} +} diff --git a/services/core/internal/api/skills_list.go b/services/core/internal/api/skills_list.go new file mode 100644 index 000000000..2daa63146 --- /dev/null +++ b/services/core/internal/api/skills_list.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// @Summary List Skills +// @Description Lists tenant-owned metadata in timestamp order. Default page size 20, maximum 100. Limit 0 returns an empty page whose has_more reports whether any Skill follows the cursor; exact hosted defaults remain unverified. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param after query string false "Skill resource cursor" +// @Param limit query integer false "Page size; 0 returns an empty page" default(20) minimum(0) maximum(100) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) +// @Success 200 {object} v1.SkillList +// @Router /skills [get] +func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.skills.ListSkills(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + result := v1.SkillList{Object: "list", Data: make([]v1.Skill, 0, len(page.Skills)), HasMore: page.HasMore} + for _, value := range page.Skills { + result.Data = append(result.Data, skillResponseResource(value)) + } + if len(result.Data) > 0 { + result.FirstID = &result.Data[0].ID + result.LastID = &result.Data[len(result.Data)-1].ID + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary List Skill versions +// @Description Orders by version number; after identifies a version resource, not a version number. An after value that does not begin with skillver, or a version of another Skill, returns 400 invalid_value with param after; a missing version returns not found. No contents are decrypted. Limit 0 returns an empty page whose has_more reports whether any version follows the cursor. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param after query string false "Version resource cursor" +// @Param limit query integer false "Page size; 0 returns an empty page" default(20) minimum(0) maximum(100) +// @Param order query string false "Version order; omit for descending, explicit empty values are invalid" Enums(asc,desc) +// @Success 200 {object} v1.SkillVersionList +// @Router /skills/{skill_id}/versions [get] +func (h *Handler) listSkillVersions(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w) { + return + } + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.skills.ListSkillVersions(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + result := v1.SkillVersionList{Object: "list", Data: make([]v1.SkillVersion, 0, len(page.Versions)), HasMore: page.HasMore} + for _, value := range page.Versions { + result.Data = append(result.Data, skillVersionResponse(value)) + } + if len(result.Data) > 0 { + result.FirstID = &result.Data[0].ID + result.LastID = &result.Data[len(result.Data)-1].ID + } + writeJSON(w, http.StatusOK, result) +} diff --git a/services/agents-api/internal/api/skills_transfer.go b/services/core/internal/api/skills_transfer.go similarity index 96% rename from services/agents-api/internal/api/skills_transfer.go rename to services/core/internal/api/skills_transfer.go index 70b748897..12d645301 100644 --- a/services/agents-api/internal/api/skills_transfer.go +++ b/services/core/internal/api/skills_transfer.go @@ -8,8 +8,8 @@ import ( "net/http" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" ) diff --git a/services/agents-api/internal/api/skills_upload.go b/services/core/internal/api/skills_upload.go similarity index 96% rename from services/agents-api/internal/api/skills_upload.go rename to services/core/internal/api/skills_upload.go index 7e5d7eaef..2c1870b97 100644 --- a/services/agents-api/internal/api/skills_upload.go +++ b/services/core/internal/api/skills_upload.go @@ -11,8 +11,8 @@ import ( "strings" "unicode/utf8" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // readSkillUpload preserves directory paths from Content-Disposition. The diff --git a/services/agents-api/internal/api/skills_upload_test.go b/services/core/internal/api/skills_upload_test.go similarity index 98% rename from services/agents-api/internal/api/skills_upload_test.go rename to services/core/internal/api/skills_upload_test.go index de3e0762f..894160ee4 100644 --- a/services/agents-api/internal/api/skills_upload_test.go +++ b/services/core/internal/api/skills_upload_test.go @@ -8,7 +8,7 @@ import ( "net/textproto" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) func TestSkillMultipartPreservesPathsAndValidatesEntireUpload(t *testing.T) { diff --git a/services/core/internal/api/source_files.go b/services/core/internal/api/source_files.go new file mode 100644 index 000000000..764380fb6 --- /dev/null +++ b/services/core/internal/api/source_files.go @@ -0,0 +1,84 @@ +package api + +import ( + "context" + "io" + "net/http" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +type SourceFileStore interface { + CreateSourceFile(context.Context, string, func(io.Writer) (store.SourceFileUpload, error)) (store.SourceFile, error) + GetSourceFile(context.Context, string, string) (store.SourceFile, error) + ListSourceFiles(context.Context, string, string, int, bool, *string) (store.SourceFilePage, error) + ReadSourceFile(context.Context, string, string, func(store.SourceFile, io.Reader) error) error + DeleteSourceFile(context.Context, string, string) error +} + +func WithSourceFiles(s SourceFileStore) Option { + return func(h *Handler) { h.sourceFiles = s } +} + +func (h *Handler) sourceFilesAvailable(w http.ResponseWriter) bool { + if h.sourceFiles == nil { + writeError(w, http.StatusServiceUnavailable, "file_storage_unavailable", "Source file storage is unavailable.") + return false + } + return true +} + +// @Summary Retrieve source file metadata +// @Description Returns immutable project-owned user_data file metadata. No Beta header is required. Other purposes, expiration and full hosted status/error semantics remain unimplemented or unverified. +// @Tags Files +// @Produce json +// @Security BearerAuth +// @Param file_id path string true "Source file ID" +// @Success 200 {object} v1.SourceFile +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files/{file_id} [get] +func (h *Handler) getSourceFile(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesAvailable(w) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + file, err := h.sourceFiles.GetSourceFile(ctx, tenantID(r), chi.URLParam(r, "file_id")) + if err != nil { + writeStoreError(w, r, err, "id") + return + } + writeJSON(w, http.StatusOK, sourceFileResponse(file)) +} + +// @Summary Delete a source file +// @Description Atomically deletes project-owned metadata and stored bytes. Already-admitted reads or copies may finish. Workspace copies remain independent. Historical WAL/backups are not erased. No Beta header is required; exact hosted concurrent deletion/error semantics remain unverified. +// @Tags Files +// @Produce json +// @Security BearerAuth +// @Param file_id path string true "Source file ID" +// @Success 200 {object} v1.SourceFileDeleted +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /files/{file_id} [delete] +func (h *Handler) deleteSourceFile(w http.ResponseWriter, r *http.Request) { + if !h.sourceFilesAvailable(w) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + id := chi.URLParam(r, "file_id") + if err := h.sourceFiles.DeleteSourceFile(ctx, tenantID(r), id); err != nil { + writeStoreError(w, r, err, "id") + return + } + writeJSON(w, http.StatusOK, v1.SourceFileDeleted{ID: id, Object: "file", Deleted: true}) +} + +func sourceFileResponse(file store.SourceFile) v1.SourceFile { + return v1.SourceFile{ID: file.ID, Object: "file", Bytes: file.SizeBytes, + CreatedAt: file.CreatedAt.Unix(), Filename: file.Filename, + Purpose: file.Purpose, Status: "processed"} +} diff --git a/services/agents-api/internal/api/source_files_content.go b/services/core/internal/api/source_files_content.go similarity index 100% rename from services/agents-api/internal/api/source_files_content.go rename to services/core/internal/api/source_files_content.go diff --git a/services/agents-api/internal/api/source_files_errors_test.go b/services/core/internal/api/source_files_errors_test.go similarity index 97% rename from services/agents-api/internal/api/source_files_errors_test.go rename to services/core/internal/api/source_files_errors_test.go index e7e7b75c9..3b9b2552b 100644 --- a/services/agents-api/internal/api/source_files_errors_test.go +++ b/services/core/internal/api/source_files_errors_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSourceFileMissingErrorParameters(t *testing.T) { diff --git a/services/agents-api/internal/api/source_files_list.go b/services/core/internal/api/source_files_list.go similarity index 97% rename from services/agents-api/internal/api/source_files_list.go rename to services/core/internal/api/source_files_list.go index e7dbaf46e..12d94eb05 100644 --- a/services/agents-api/internal/api/source_files_list.go +++ b/services/core/internal/api/source_files_list.go @@ -3,7 +3,7 @@ package api import ( "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) // @Summary List source files diff --git a/services/agents-api/internal/api/source_files_list_test.go b/services/core/internal/api/source_files_list_test.go similarity index 97% rename from services/agents-api/internal/api/source_files_list_test.go rename to services/core/internal/api/source_files_list_test.go index 2e82cdcef..5106b5496 100644 --- a/services/agents-api/internal/api/source_files_list_test.go +++ b/services/core/internal/api/source_files_list_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSourceFileListParametersAndEnvelope(t *testing.T) { diff --git a/services/agents-api/internal/api/source_files_test.go b/services/core/internal/api/source_files_test.go similarity index 97% rename from services/agents-api/internal/api/source_files_test.go rename to services/core/internal/api/source_files_test.go index aaa176ba9..7a37e198d 100644 --- a/services/agents-api/internal/api/source_files_test.go +++ b/services/core/internal/api/source_files_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/source_files_upload.go b/services/core/internal/api/source_files_upload.go similarity index 98% rename from services/agents-api/internal/api/source_files_upload.go rename to services/core/internal/api/source_files_upload.go index 23475e492..339ff5cc7 100644 --- a/services/agents-api/internal/api/source_files_upload.go +++ b/services/core/internal/api/source_files_upload.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const sourceTransferTimeout = 5 * time.Minute diff --git a/services/agents-api/internal/api/stream.go b/services/core/internal/api/stream.go similarity index 98% rename from services/agents-api/internal/api/stream.go rename to services/core/internal/api/stream.go index 3814fb72e..a79e043fb 100644 --- a/services/agents-api/internal/api/stream.go +++ b/services/core/internal/api/stream.go @@ -9,9 +9,9 @@ import ( "strings" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/stream_authority_test.go b/services/core/internal/api/stream_authority_test.go similarity index 93% rename from services/agents-api/internal/api/stream_authority_test.go rename to services/core/internal/api/stream_authority_test.go index a21c19a96..9d2608321 100644 --- a/services/agents-api/internal/api/stream_authority_test.go +++ b/services/core/internal/api/stream_authority_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/stream_test.go b/services/core/internal/api/stream_test.go similarity index 97% rename from services/agents-api/internal/api/stream_test.go rename to services/core/internal/api/stream_test.go index e4c70013a..95b6a4d97 100644 --- a/services/agents-api/internal/api/stream_test.go +++ b/services/core/internal/api/stream_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/core/internal/api/subagent_turns.go similarity index 100% rename from services/agents-api/internal/api/subagent_turns.go rename to services/core/internal/api/subagent_turns.go diff --git a/services/core/internal/api/subagents.go b/services/core/internal/api/subagents.go new file mode 100644 index 000000000..e0a645c48 --- /dev/null +++ b/services/core/internal/api/subagents.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// SubagentStore reads tenant-authorized, persisted public resources. Implementations +// must enforce every supplied parent scope, including the pagination cursor. +type SubagentStore interface { + GetSubagent(context.Context, string, string, string) (v1.Subagent, error) + ListSubagents(context.Context, string, string, string, int, bool) (v1.SubagentList, error) + ListSubagentItems(context.Context, string, string, string, string, int, bool) (v1.ItemList, error) + GetSubagentTurn(context.Context, string, string, string, string) (v1.Turn, error) + ListSubagentTurns(context.Context, string, string, string, string, int, bool) (v1.TurnList, error) + ListSubagentTurnItems(context.Context, string, string, string, string, string, int, bool) (v1.ItemList, error) +} + +func WithSubagents(s SubagentStore) Option { return func(h *Handler) { h.subagents = s } } + +func (h *Handler) registerSubagentRoutes(r chi.Router) { + const root = "/agents/sessions/{session_id}/subagents" + r.Get(root, h.listSubagents) + r.Get(root+"/{subagent_id}", h.getSubagent) + r.Get(root+"/{subagent_id}/items", h.listSubagentItems) + r.Get(root+"/{subagent_id}/turns", h.listSubagentTurns) + r.Get(root+"/{subagent_id}/turns/{turn_id}", h.getSubagentTurn) + r.Get(root+"/{subagent_id}/turns/{turn_id}/items", h.listSubagentTurnItems) +} + +func (h *Handler) subagentsReady(w http.ResponseWriter) bool { + if h.subagents == nil { + writeError(w, http.StatusServiceUnavailable, "subagent_storage_unavailable", "Subagent storage is unavailable.") + return false + } + return true +} + +// @Summary Retrieve a Session Subagent +// @Description Returns this Session's persisted Subagent. Active includes idle between Turns. Resuming preserves opened_at and clears closed_at. Unknown or inaccessible parent scopes return not found. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Success 200 {object} v1.Subagent +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id} [get] +func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { + if !h.subagentsReady(w) { + return + } + value, err := h.subagents.GetSubagent(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, value) +} + +// @Summary List Session Subagents +// @Description Includes nested and closed Subagents. Cursors are Subagents of the same tenant and Session. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid resource ID in `after`". A limit outside 1–100 is rejected. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param after query string false "Last Subagent ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SubagentList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents [get] +func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagents(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, subagentListResponse(page.Data, page.HasMore)) +} + +// @Summary List a Subagent's Items +// @Description Returns only this Subagent's own Items across all its Turns, not its descendants' Items. Cursors are Items of the same tenant, Session and Subagent. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid session item ID in `after`". +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Param after query string false "Last Item ID from the previous page" +// @Param limit query int false "Page size; 0 is treated as 1 and values above 100 as 100" minimum(0) default(20) +// @Param order query string false "Resource order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.ItemList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/items [get] +func (h *Handler) listSubagentItems(w http.ResponseWriter, r *http.Request) { + options, ok := readClampedPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagentItems(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, itemListResponse(page.Data, page.HasMore)) +} diff --git a/services/core/internal/api/subagents_test.go b/services/core/internal/api/subagents_test.go new file mode 100644 index 000000000..504400637 --- /dev/null +++ b/services/core/internal/api/subagents_test.go @@ -0,0 +1,279 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type subagentReadStore struct { + method, tenant, session, subagent, turn, after string + limit int + ascending bool + calls int + empty bool + err error +} + +func (s *subagentReadStore) record(method, tenant, session, subagent, turn, after string, limit int, ascending bool) { + s.method, s.tenant, s.session, s.subagent, s.turn, s.after = method, tenant, session, subagent, turn, after + s.limit, s.ascending = limit, ascending + s.calls++ +} + +func sampleSubagent() v1.Subagent { + return v1.Subagent{ID: "child", Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", OpenedAt: 1700000000, Status: "active"} +} + +// sampleSubagentTurn has the official child Turn shape: agent_id is the Session's +// Agent ID and subagent_id identifies the child. +func sampleSubagentTurn() v1.Turn { + id := "child" + return v1.Turn{ID: "turn", AgentID: "root", SubagentID: &id, SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000001} +} + +func (s *subagentReadStore) GetSubagent(_ context.Context, tenant, session, subagent string) (v1.Subagent, error) { + s.record("get", tenant, session, subagent, "", "", 0, false) + return sampleSubagent(), s.err +} + +func (s *subagentReadStore) ListSubagents(_ context.Context, tenant, session, after string, limit int, asc bool) (v1.SubagentList, error) { + s.record("list", tenant, session, "", "", after, limit, asc) + if s.empty { + return v1.SubagentList{}, s.err + } + return v1.SubagentList{Data: []v1.Subagent{sampleSubagent()}, HasMore: true}, s.err +} + +func (s *subagentReadStore) ListSubagentItems(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.ItemList, error) { + s.record("items", tenant, session, subagent, "", after, limit, asc) + return s.items(), s.err +} + +func (s *subagentReadStore) GetSubagentTurn(_ context.Context, tenant, session, subagent, turn string) (v1.Turn, error) { + s.record("turn", tenant, session, subagent, turn, "", 0, false) + return sampleSubagentTurn(), s.err +} + +func (s *subagentReadStore) ListSubagentTurns(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.TurnList, error) { + s.record("turns", tenant, session, subagent, "", after, limit, asc) + if s.empty { + return v1.TurnList{}, s.err + } + return v1.TurnList{Data: []v1.Turn{sampleSubagentTurn()}, HasMore: true}, s.err +} + +func (s *subagentReadStore) ListSubagentTurnItems(_ context.Context, tenant, session, subagent, turn, after string, limit int, asc bool) (v1.ItemList, error) { + s.record("turn_items", tenant, session, subagent, turn, after, limit, asc) + return s.items(), s.err +} + +func (s *subagentReadStore) items() v1.ItemList { + if s.empty { + return v1.ItemList{} + } + text := "Child result." + return v1.ItemList{Data: []v1.Item{{ID: "item", TurnID: "turn", Type: "agent_message", SenderAgentID: "child", RecipientAgentID: "root", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}, HasMore: true} +} + +// Item lists clamp limit like Session Items; the Subagent and Subagent Turn lists +// reject it, as the official service does. +var subagentRoutes = []struct { + path, method, subagent, turn string + list, clamped bool +}{ + {"", "list", "", "", true, false}, + {"/child", "get", "child", "", false, false}, + {"/child/items", "items", "child", "", true, true}, + {"/child/turns", "turns", "child", "", true, false}, + {"/child/turns/turn", "turn", "child", "turn", false, false}, + {"/child/turns/turn/items", "turn_items", "child", "turn", true, true}, +} + +func requestSubagents(h http.Handler, path, auth, beta string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/session/subagents"+path, nil) + r.Header.Set("Authorization", auth) + r.Header.Set("OpenAI-Beta", beta) + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestSubagentRoutesPreserveAuthenticatedParentScope(t *testing.T) { + s := &subagentReadStore{} + h, _, tenant := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + t.Run(route.method, func(t *testing.T) { + w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.method != route.method || s.tenant != tenant || s.session != "session" || s.subagent != route.subagent || s.turn != route.turn { + t.Fatalf("scope: %d %s; call=%+v", w.Code, w.Body, s) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if route.list { + if s.limit != 20 || s.ascending || s.after != "" || string(body["has_more"]) != "true" { + t.Fatalf("list defaults: %+v %s", s, w.Body) + } + // Every Subagent list uses the common envelope. + if string(body["object"]) != `"list"` || len(body["first_id"]) < 3 || string(body["first_id"]) != string(body["last_id"]) { + t.Fatalf("list envelope: %s", w.Body) + } + w = requestSubagents(h, route.path+"?after=last&limit=2&order=asc", "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.after != "last" || s.limit != 2 || !s.ascending { + t.Fatalf("pagination: %+v %s", s, w.Body) + } + } else if route.method == "get" { + for _, field := range []string{"closed_at", "name", "instructions"} { + if string(body[field]) != "null" { + t.Fatalf("%s missing null: %s", field, w.Body) + } + } + } else if string(body["agent_id"]) != `"root"` || string(body["subagent_id"]) != `"child"` || string(body["usage"]) != "null" { + t.Fatalf("child Turn identity: %s", w.Body) + } + }) + } +} + +func TestSubagentRoutesRejectInvalidQueriesBeforeStore(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + if !route.list { + continue + } + queries := []string{"limit=null", "limit=-1", "limit=2&limit=3", "order=random", "order=asc&order=desc", "after=a&after=b"} + if !route.clamped { + queries = append(queries, "limit=0", "limit=101") + } + for _, query := range queries { + calls := s.calls + w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusBadRequest || s.calls != calls || !strings.Contains(w.Body.String(), `"code":"invalid_request_error"`) { + t.Fatalf("accepted %s?%s: %d %s", route.path, query, w.Code, w.Body) + } + } + } +} + +func TestSubagentItemListsClampLimit(t *testing.T) { + s := &subagentReadStore{} + h, _, tenant := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + if !route.clamped { + continue + } + for query, limit := range map[string]int{"limit=0": 1, "limit=1": 1, "limit=100": 100, "limit=101": 100, "limit=100000": 100} { + calls := s.calls + w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.calls != calls+1 || s.method != route.method || s.limit != limit || s.tenant != tenant { + t.Fatalf("%s?%s: %d %s %+v", route.path, query, w.Code, w.Body, s) + } + } + } +} + +func TestSubagentRoutesIgnoreUnknownQueryKeys(t *testing.T) { + s := &subagentReadStore{} + h, _, tenant := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + // Retrieval routes also ignore list keys, which carry no semantics there. + for _, query := range []string{"unknown=1", "tenant_id=foreign&unknown=1&unknown=2", "limit=1&after=a&order=asc"} { + if route.list && strings.Contains(query, "limit") { + continue + } + calls := s.calls + w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.calls != calls+1 || s.method != route.method || s.tenant != tenant || s.subagent != route.subagent || s.turn != route.turn { + t.Fatalf("query changed %s?%s: %d %s %+v", route.path, query, w.Code, w.Body, s) + } + if route.list && (s.limit != 20 || s.after != "" || s.ascending) { + t.Fatalf("unknown keys changed pagination: %+v", s) + } + } + } +} + +func TestSubagentRoutesUseExistingAuthenticationAndErrors(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + for _, tc := range []struct { + auth, beta string + status int + }{ + {"", "agents=v1", 401}, + {"Bearer wrong", "agents=v1", 401}, + {"Bearer test-api-key", "", 400}, + {"Bearer test-api-key", "agents=v2", 400}, + } { + calls := s.calls + w := requestSubagents(h, route.path, tc.auth, tc.beta) + if w.Code != tc.status || s.calls != calls { + t.Fatalf("authentication %s: %d %s", route.path, w.Code, w.Body) + } + } + for _, tc := range []struct { + err error + status int + }{ + {store.ErrNotFound, 404}, + {store.ErrInvalidInput, 400}, + {errors.New("SECRET native failure"), 500}, + } { + s.err = tc.err + w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != tc.status || strings.Contains(w.Body.String(), "SECRET") { + t.Fatalf("error %s: %d %s", route.path, w.Code, w.Body) + } + } + s.err = nil + } +} + +func TestSubagentRoutesDistinguishEmptyFromUnavailable(t *testing.T) { + s := &subagentReadStore{empty: true} + h, _, _ := testHandler(t, WithSubagents(s)) + unavailable, _, _ := testHandler(t) + for _, route := range subagentRoutes { + w := requestSubagents(unavailable, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("unwired store fabricated success: %d %s", w.Code, w.Body) + } + if route.list { + w = requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + expected := `{"object":"list","first_id":null,"last_id":null,"data":[],"has_more":false}` + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != expected { + t.Fatalf("empty page: %d %s", w.Code, w.Body) + } + } + } +} + +func TestSubagentRoutesExposeOnlyOfficialReads(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + for _, method := range []string{http.MethodPost, http.MethodPatch, http.MethodDelete} { + r := httptest.NewRequest(method, "/v1/agents/sessions/session/subagents"+route.path, nil) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusMethodNotAllowed || s.calls != 0 { + t.Fatalf("unexpected mutation route %s %s: %d %s", method, route.path, w.Code, w.Body) + } + } + } +} diff --git a/services/agents-api/internal/api/text_configuration.go b/services/core/internal/api/text_configuration.go similarity index 90% rename from services/agents-api/internal/api/text_configuration.go rename to services/core/internal/api/text_configuration.go index b7fac7b6c..dca8f5358 100644 --- a/services/agents-api/internal/api/text_configuration.go +++ b/services/core/internal/api/text_configuration.go @@ -2,7 +2,7 @@ package api import ( "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func resolveText(input *v1.TextConfigInput) (v1.TextConfig, error) { diff --git a/services/agents-api/internal/api/text_configuration_test.go b/services/core/internal/api/text_configuration_test.go similarity index 97% rename from services/agents-api/internal/api/text_configuration_test.go rename to services/core/internal/api/text_configuration_test.go index 78ee6d2e8..cbe427f42 100644 --- a/services/agents-api/internal/api/text_configuration_test.go +++ b/services/core/internal/api/text_configuration_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func TestTextConfigurationHTTP(t *testing.T) { diff --git a/services/agents-api/internal/api/tool_search_response_test.go b/services/core/internal/api/tool_search_response_test.go similarity index 92% rename from services/agents-api/internal/api/tool_search_response_test.go rename to services/core/internal/api/tool_search_response_test.go index 2ae331a52..1f836640f 100644 --- a/services/agents-api/internal/api/tool_search_response_test.go +++ b/services/core/internal/api/tool_search_response_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestDiscoveryResourceProjectionRetainsExecutionConfiguration(t *testing.T) { diff --git a/services/agents-api/internal/api/turn_diagnostic_failure.go b/services/core/internal/api/turn_diagnostic_failure.go similarity index 94% rename from services/agents-api/internal/api/turn_diagnostic_failure.go rename to services/core/internal/api/turn_diagnostic_failure.go index 05623019d..25801f515 100644 --- a/services/agents-api/internal/api/turn_diagnostic_failure.go +++ b/services/core/internal/api/turn_diagnostic_failure.go @@ -3,8 +3,8 @@ package api import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func turnDiagnosticFailure(turn store.Turn) *DiagnosticFailure { diff --git a/services/agents-api/internal/api/turn_diagnostic_failure_test.go b/services/core/internal/api/turn_diagnostic_failure_test.go similarity index 98% rename from services/agents-api/internal/api/turn_diagnostic_failure_test.go rename to services/core/internal/api/turn_diagnostic_failure_test.go index b8d015eb0..b1a6733a9 100644 --- a/services/agents-api/internal/api/turn_diagnostic_failure_test.go +++ b/services/core/internal/api/turn_diagnostic_failure_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestDiagnosticFailureWhitelist(t *testing.T) { diff --git a/services/core/internal/api/turns.go b/services/core/internal/api/turns.go new file mode 100644 index 000000000..693edb502 --- /dev/null +++ b/services/core/internal/api/turns.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Retrieve an execution Turn +// @Description Returns a root Turn of this Session. A Subagent Turn ID returns the same not found error as a missing Turn; read it through the Subagent Turn routes. +// @Tags Turns +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param turn_id path string true "Turn ID" +// @Success 200 {object} v1.Turn +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/turns/{turn_id} [get] +func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "session_id") + turn, err := h.store.GetTurn(r.Context(), tenantID(r), sessionID, chi.URLParam(r, "turn_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := turnResponse(session, turn) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, response) +} + +// @Summary List execution Turns +// @Description Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same Session and tenant; any other after value, including a malformed one or a Subagent Turn ID, returns not found. Usage contains the latest recorded complete token breakdown; missing measurements remain null. +// @Tags Turns +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param after query string false "Last Turn ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.TurnList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/turns [get] +func (h *Handler) listTurns(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok { + return + } + sessionID := chi.URLParam(r, "session_id") + session, err := h.store.GetSession(r.Context(), tenantID(r), sessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + page, err := h.store.ListTurns(r.Context(), tenantID(r), sessionID, options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.TurnList{Data: make([]v1.Turn, 0, len(page.Turns)), HasMore: page.NextCursor != ""} + for _, turn := range page.Turns { + item, err := turnResponse(session, turn) + if err != nil { + writeStoreError(w, r, err) + return + } + response.Data = append(response.Data, item) + } + writeJSON(w, http.StatusOK, turnListResponse(response.Data, response.HasMore)) +} + +func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { + var cfg configuration + if err := json.Unmarshal(session.Configuration, &cfg); err != nil || cfg.Agent.ID == "" { + return v1.Turn{}, errors.New("missing stored agent identity") + } + // Session Turns are root Turns, so subagent_id is always null here. + response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} + if turn.Status == store.TurnFailed { + // Native errors can contain secrets; publish a stable category without raw diagnostics. + response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} + } + return response, nil +} + +func unixTime(value time.Time) *int64 { + if value.IsZero() { + return nil + } + seconds := value.Unix() + return &seconds +} diff --git a/services/agents-api/internal/api/turns_test.go b/services/core/internal/api/turns_test.go similarity index 97% rename from services/agents-api/internal/api/turns_test.go rename to services/core/internal/api/turns_test.go index 10e85d0a9..8d8044382 100644 --- a/services/agents-api/internal/api/turns_test.go +++ b/services/core/internal/api/turns_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type turnReadStore struct { diff --git a/services/core/internal/api/usage.go b/services/core/internal/api/usage.go new file mode 100644 index 000000000..31db5f7af --- /dev/null +++ b/services/core/internal/api/usage.go @@ -0,0 +1,14 @@ +package api + +import ( + "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func tokenUsage(raw json.RawMessage) *v1.TokenUsage { + var usage *v1.TokenUsage + if json.Unmarshal(raw, &usage) != nil { + return nil + } + return usage +} diff --git a/services/agents-api/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go similarity index 99% rename from services/agents-api/internal/api/validation_errors_test.go rename to services/core/internal/api/validation_errors_test.go index 28b713c93..ff69e7a70 100644 --- a/services/agents-api/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" ) diff --git a/services/agents-api/internal/api/vault_pagination.go b/services/core/internal/api/vault_pagination.go similarity index 100% rename from services/agents-api/internal/api/vault_pagination.go rename to services/core/internal/api/vault_pagination.go diff --git a/services/agents-api/internal/api/vault_pagination_test.go b/services/core/internal/api/vault_pagination_test.go similarity index 100% rename from services/agents-api/internal/api/vault_pagination_test.go rename to services/core/internal/api/vault_pagination_test.go diff --git a/services/core/internal/api/vaults.go b/services/core/internal/api/vaults.go new file mode 100644 index 000000000..e1df8c965 --- /dev/null +++ b/services/core/internal/api/vaults.go @@ -0,0 +1,118 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type VaultStore interface { + CreateVault(context.Context, string, store.CreateVaultInput) (store.Vault, error) + GetVault(context.Context, string, string) (store.Vault, error) + DeleteVault(context.Context, string, string) (string, error) + ListVaults(context.Context, string, string, int, bool, []string) (store.VaultPage, error) +} + +// @Summary Create a Vault +// @Description Creates a project-owned Vault independently of execution. Omitted name stays null; a supplied string is trimmed and must contain 1–256 UTF-8 bytes. Explicit null name is invalid. Omitted/null metadata becomes an empty object; non-string values return invalid_request_error with a metadata. param. Metadata has a local 64 KiB encoded storage bound. U+0000 in stored strings is rejected as a local storage limit. Credentials, Session binding and hosted error/retry parity remain incomplete. +// @Tags Vaults +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.CreateVaultRequest true "Vault name and metadata" +// @Success 201 {object} v1.Vault +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /vaults [post] +func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObject(w, r) + if !ok { + return + } + if writeFieldError(w, metadataTypeError(raw)) { + return + } + var request struct { + Name json.RawMessage `json:"name"` + Metadata map[string]*string `json:"metadata"` + } + if decodeInputObject(raw, &request, "name", "metadata") != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") + return + } + input := store.CreateVaultInput{} + if len(request.Name) > 0 { + var name *string + if json.Unmarshal(request.Name, &name) != nil || name == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "name must be a string.") + return + } + trimmed, err := normalizedVaultName(*name) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + input.Name = &trimmed + } + // Vault metadata has no pair or character limits, only the storage limits. + var err error + input.Metadata, err = stringMetadata(request.Metadata) + if err == nil { + err = metadataCharacterError(input.Metadata) + } + if err != nil { + if !writeFieldError(w, err) { + writeError(w, http.StatusBadRequest, "invalid_request", "metadata values must be strings.") + } + return + } + vault, err := h.store.CreateVault(r.Context(), tenantID(r), input) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, vaultResponse(vault)) +} + +// @Summary Retrieve a Vault +// @Description Reads a Vault owned by the authenticated project without resolving credentials, Sessions or execution devices. Missing and foreign IDs share the same not-found response; exact hosted error semantics remain unverified. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Success 200 {object} v1.Vault +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id} [get] +func (h *Handler) getVault(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "vault_id") + if parsed, err := uuid.Parse(id); err != nil || parsed == uuid.Nil { + writeStoreError(w, r, store.ErrNotFound) + return + } + vault, err := h.store.GetVault(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, vaultResponse(vault)) +} + +func vaultResponse(vault store.Vault) v1.Vault { + return v1.Vault{ID: vault.ID, Object: "vault", CreatedAt: vault.CreatedAt.Unix(), Name: vault.Name, Metadata: vault.Metadata} +} + +func normalizedVaultName(name string) (string, error) { + name = strings.TrimSpace(name) + if len(name) == 0 || len(name) > 256 { + return "", errors.New("name must contain 1 to 256 UTF-8 bytes after trimming.") + } + return name, nil +} diff --git a/services/core/internal/api/vaults_delete.go b/services/core/internal/api/vaults_delete.go new file mode 100644 index 000000000..3534e6e46 --- /dev/null +++ b/services/core/internal/api/vaults_delete.go @@ -0,0 +1,39 @@ +package api + +import ( + "bytes" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// @Summary Delete a Vault and all its Credentials +// @Description Atomically removes the authenticated project's Vault and all its stored Credentials without an encryption key, decryption or external requests. Existing Session snapshots, history and recorded retries retain their frozen identities; subsequent credential lookups fail without reselection or anonymous fallback. Already-resolved tokens and running Sessions are not revoked or cancelled. Missing/repeated deletion locally returns 404. Exact hosted archive, post-delete visibility and concurrent/error semantics remain unverified; physical erasure from native history, WAL or backups is not established. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param vault_id path string true "Vault ID" +// @Success 200 {object} v1.VaultDeleted +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /vaults/{vault_id} [delete] +func (h *Handler) deleteVault(w http.ResponseWriter, r *http.Request) { + body, ok := readJSONBody(w, r) + if !ok { + return + } + if len(bytes.TrimSpace(body)) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Vault deletion does not accept a request body.") + return + } + id, ok := credentialResourceID(w, r, "vault_id") + if !ok { + return + } + deleted, err := h.store.DeleteVault(r.Context(), tenantID(r), id) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.VaultDeleted{ID: deleted, Deleted: true, Object: "vault.deleted"}) +} diff --git a/services/core/internal/api/vaults_delete_test.go b/services/core/internal/api/vaults_delete_test.go new file mode 100644 index 000000000..57e2e21e4 --- /dev/null +++ b/services/core/internal/api/vaults_delete_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func (f *vaultResourceFixture) DeleteVault(_ context.Context, tenant, id string) (string, error) { + f.tenant, f.id, f.calls = tenant, id, f.calls+1 + return f.vault.ID, f.err +} + +func TestVaultDeletionConfirmationAndScope(t *testing.T) { + // Unknown query keys, including a tenant hint, are ignored. + for _, query := range []string{"", "?tenant_id=untrusted&unknown=1"} { + h, f := vaultResourceHandler(t) + w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID+query, "") + var got map[string]any + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatal("deletion failed", w.Code) + } + want := map[string]any{"id": f.vault.ID, "deleted": true, "object": "vault.deleted"} + if !reflect.DeepEqual(got, want) || f.tenant != f.vault.TenantID || f.id != f.vault.ID || f.calls != 1 { + t.Fatal("deletion changed authenticated scope or confirmation") + } + } +} + +func TestVaultDeletionRejectsBeforeMutation(t *testing.T) { + for _, mode := range []string{"auth", "beta", "body", "null", "invalid", "zero", "zero-query"} { + t.Run(mode, func(t *testing.T) { + h, f := vaultResourceHandler(t) + path, body, status := "/v1/vaults/"+f.vault.ID, "", 400 + switch mode { + case "auth": + status = 401 + case "body": + body = `{"token":"vault-delete-canary"}` + case "null": + body = "null" + case "invalid": + path, status = "/v1/vaults/invalid", 404 + case "zero": + path, status = "/v1/vaults/"+uuid.Nil.String(), 404 + case "zero-query": + path, status = "/v1/vaults/"+uuid.Nil.String()+"?tenant_id=untrusted", 404 + } + r := httptest.NewRequest("DELETE", path, strings.NewReader(body)) + if mode != "auth" { + r.Header.Set("Authorization", "Bearer vault-key") + } + if mode != "beta" { + r.Header.Set("OpenAI-Beta", "agents=v1") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status || f.calls != 0 || strings.Contains(w.Body.String(), "vault-delete-canary") { + t.Fatal("invalid deletion reached storage or exposed input", w.Code) + } + }) + } + for _, tc := range []struct { + err error + status int + }{{store.ErrNotFound, 404}, {errors.New("vault-delete-canary"), 500}} { + h, f := vaultResourceHandler(t) + f.err = tc.err + w := vaultRequest(h, "DELETE", "/v1/vaults/"+f.vault.ID, "") + if w.Code != tc.status || strings.Contains(w.Body.String(), "vault-delete-canary") { + t.Fatal("deletion changed error mapping or exposed storage detail") + } + } +} diff --git a/services/core/internal/api/vaults_list.go b/services/core/internal/api/vaults_list.go new file mode 100644 index 000000000..f83e08d2c --- /dev/null +++ b/services/core/internal/api/vaults_list.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// @Summary List Vaults +// @Description Lists project-owned Vaults independently of execution. An unknown, malformed or foreign after cursor returns not found. Includes active and archived records by default. Status accepts a scalar, the SDK's status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted errors and concurrent-page behavior remain unverified. Archive/delete lifecycle is not implemented. +// @Tags Vaults +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Last Vault ID from the previous page" +// @Param limit query integer false "Requested page size, clamped to 1–100" default(20) +// @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) default(desc) +// @Param status query string false "Scalar status filter" Enums(active,archived) +// @Param status[] query []string false "Array status filter; combined with status as a union" collectionFormat(multi) Enums(active,archived) +// @Success 200 {object} v1.VaultList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /vaults [get] +func (h *Handler) listVaults(w http.ResponseWriter, r *http.Request) { + options, statuses, ok := readVaultPage(w, r) + if !ok { + return + } + page, err := h.store.ListVaults(r.Context(), tenantID(r), options.after, options.limit, options.ascending, statuses) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.VaultList{Object: "list", Data: make([]v1.Vault, 0, len(page.Vaults)), HasMore: page.NextCursor != ""} + for _, vault := range page.Vaults { + response.Data = append(response.Data, vaultResponse(vault)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/vaults_list_test.go b/services/core/internal/api/vaults_list_test.go similarity index 95% rename from services/agents-api/internal/api/vaults_list_test.go rename to services/core/internal/api/vaults_list_test.go index 186c2623a..0c8d2bc88 100644 --- a/services/agents-api/internal/api/vaults_list_test.go +++ b/services/core/internal/api/vaults_list_test.go @@ -7,8 +7,8 @@ import ( "reflect" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (f *vaultResourceFixture) ListVaults(_ context.Context, tenant, after string, limit int, ascending bool, statuses []string) (store.VaultPage, error) { diff --git a/services/agents-api/internal/api/vaults_test.go b/services/core/internal/api/vaults_test.go similarity index 97% rename from services/agents-api/internal/api/vaults_test.go rename to services/core/internal/api/vaults_test.go index 2d2a3bef4..f88b6cc61 100644 --- a/services/agents-api/internal/api/vaults_test.go +++ b/services/core/internal/api/vaults_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/api/write_audit.go b/services/core/internal/api/write_audit.go new file mode 100644 index 000000000..23d598e0a --- /dev/null +++ b/services/core/internal/api/write_audit.go @@ -0,0 +1,164 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// WriteAuditStore exposes safe read models, never request bodies or credentials. +type WriteAuditStore interface { + GetResourceOwners(context.Context, string, string, []string) ([]store.ResourceOwner, error) + ListWriteOperations(context.Context, string, store.WriteOperationFilter) (store.WriteOperationPage, error) +} + +type ResourceOwnerList struct { + Data []store.ResourceOwner `json:"data"` +} + +func WithWriteAudit(s WriteAuditStore, auth *DeploymentAuthenticator) Option { + return func(h *Handler) { + h.writeAudit = s + if auth != nil { + h.deploymentAuth = auth + } + } +} + +func (h *Handler) writeAuditScope(w http.ResponseWriter, r *http.Request, allowed ...string) (url.Values, string, bool) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return nil, "", false + } + for name, entries := range values { + recognized := false + for _, field := range allowed { + if name == field { + recognized = true + } + } + if !recognized || len(entries) != 1 || entries[0] == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return nil, "", false + } + } + tenant, ok := r.Context().Value(adminTenantContextKey{}).(string) + if !ok || tenant == "" { + writeStoreError(w, r, store.ErrNotFound) + return nil, "", false + } + return values, tenant, true +} + +// @Summary Batch lookup resource creation keys +// @Description Core key only. The Project ID path selects its space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. +// @Tags Write Audit +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param resource_type query string true "Resource type" +// @Param resource_ids query string true "Comma-separated public resource IDs, maximum 100" +// @Success 200 {object} api.ResourceOwnerList +// @Failure 400,401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/resource-owners [get] +func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { + values, tenant, ok := h.writeAuditScope(w, r, "resource_type", "resource_ids") + if !ok { + return + } + ids := strings.Split(values.Get("resource_ids"), ",") + if !store.ValidAuditResourceType(values.Get("resource_type")) || len(ids) > 100 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + for _, id := range ids { + if id == "" || len(id) > 256 || strings.TrimSpace(id) != id { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + owners, err := h.writeAudit.GetResourceOwners(r.Context(), tenant, values.Get("resource_type"), ids) + if err != nil { + writeStoreError(w, r, err) + return + } + if owners == nil { + owners = []store.ResourceOwner{} + } + writeJSON(w, http.StatusOK, ResourceOwnerList{Data: owners}) +} + +// @Summary Query API-key write operations +// @Description Core key only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. +// @Tags Write Audit +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project ID" +// @Param key_id query string false "Recorded creator key ID" +// @Param resource_type query string false "Resource type" +// @Param resource_id query string false "Public resource ID" +// @Param created_after query string false "Inclusive RFC3339 timestamp" +// @Param created_before query string false "Exclusive RFC3339 timestamp" +// @Param limit query int false "Page size, 1-100, default 50" +// @Param after query string false "Opaque next_cursor from the preceding page" +// @Success 200 {object} store.WriteOperationPage +// @Failure 400,401,404,500 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/write-operations [get] +func (h *Handler) listWriteOperations(w http.ResponseWriter, r *http.Request) { + values, tenant, ok := h.writeAuditScope(w, r, "key_id", "resource_type", "resource_id", "created_after", "created_before", "limit", "after") + if !ok { + return + } + filter := store.WriteOperationFilter{KeyID: values.Get("key_id"), ResourceType: values.Get("resource_type"), ResourceID: values.Get("resource_id"), After: values.Get("after"), Limit: 50} + if filter.ResourceType != "" && !store.ValidAuditResourceType(filter.ResourceType) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + for _, value := range []string{filter.KeyID, filter.ResourceID} { + if len(value) > 256 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + if len(filter.After) > 2048 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if value := values.Get("limit"); value != "" { + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 100 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + filter.Limit = n + } + for key, target := range map[string]**time.Time{"created_after": &filter.CreatedAfter, "created_before": &filter.CreatedBefore} { + if value := values.Get(key); value != "" { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + *target = &parsed + } + } + if filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + page, err := h.writeAudit.ListWriteOperations(r.Context(), tenant, filter) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []store.WriteOperation{} + } + writeJSON(w, http.StatusOK, page) +} diff --git a/services/core/internal/api/write_audit_test.go b/services/core/internal/api/write_audit_test.go new file mode 100644 index 000000000..1e55dbd35 --- /dev/null +++ b/services/core/internal/api/write_audit_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" +) + +type auditQueryFixture struct { + tenant, resourceType string + ids []string + filter store.WriteOperationFilter + calls int +} + +func (s *auditQueryFixture) GetResourceOwners(_ context.Context, tenant, kind string, ids []string) ([]store.ResourceOwner, error) { + s.calls++ + s.tenant = tenant + s.resourceType = kind + s.ids = ids + result := make([]store.ResourceOwner, len(ids)) + for i, id := range ids { + result[i].ResourceID = id + } + return result, nil +} +func (s *auditQueryFixture) ListWriteOperations(_ context.Context, tenant string, filter store.WriteOperationFilter) (store.WriteOperationPage, error) { + s.calls++ + s.tenant = tenant + s.filter = filter + return store.WriteOperationPage{}, nil +} +func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { + key := callerBinding() + auth, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + if err != nil { + t.Fatal(err) + } + queries := &auditQueryFixture{} + h, err := NewHandler(&recordingStore{}, auth, "codex", WithWriteAudit(queries, admin), WithProjectAPIKeys(&projectKeyStoreFixture{project: store.ProjectBinding{Project: store.Project{ID: key.ProjectID}, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID}}}}, admin)) + if err != nil { + t.Fatal(err) + } + owners := "/core/v1/projects/" + key.ProjectID + "/resource-owners?resource_type=agent&resource_ids=first,second" + for _, token := range []string{"", "caller", "foreign"} { + w := projectKeyHTTP(h, "GET", owners, token, "") + if w.Code != 401 || queries.calls != 0 { + t.Fatalf("unauthorized query %d calls%d", w.Code, queries.calls) + } + } + w := projectKeyHTTP(h, "GET", owners, "admin", "") + if w.Code != 200 || queries.tenant != key.TenantID || queries.resourceType != "agent" || len(queries.ids) != 2 { + t.Fatalf("batch %d %s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), `"api_key":null`) { + t.Fatalf("history must be null: %s", w.Body) + } + history := "/core/v1/projects/" + key.ProjectID + "/write-operations?" + w = projectKeyHTTP(h, "GET", history+"key_id=some-key&resource_type=credential&resource_id=resource&limit=2&after=cursor&created_after=2026-01-01T00:00:00Z&created_before=2026-02-01T00:00:00Z", "admin", "") + if w.Code != 200 || queries.filter.Limit != 2 || queries.filter.KeyID != "some-key" || queries.filter.After != "cursor" || queries.filter.CreatedAfter == nil || queries.filter.CreatedBefore == nil { + t.Fatalf("history %d %s filter%+v", w.Code, w.Body, queries.filter) + } + for _, path := range []string{ + owners + "&resource_type=file", strings.Replace(owners, "agent", "unknown", 1), strings.Replace(owners, "first,second", "", 1), + owners + "&tenant_id=foreign", owners + "&bad=%GG", strings.Replace(owners, "first,second", strings.Repeat("id,", 100)+"id", 1), + history + "limit=0", history + "limit=101", history + "limit=bad", history + "limit=", history + "created_after=yesterday", history + "resource_type=unknown", + history + "created_after=2026-02-01T00:00:00Z&created_before=2026-01-01T00:00:00Z", + } { + count := queries.calls + w = projectKeyHTTP(h, "GET", path, "admin", "") + if w.Code != 400 || queries.calls != count { + t.Errorf("invalid query %s returned%d", path, w.Code) + } + } + count := queries.calls + w = projectKeyHTTP(h, "GET", strings.Replace(owners, key.ProjectID, "missing-project", 1), "admin", "") + if w.Code != 404 || queries.calls != count { + t.Fatalf("missing binding %d", w.Code) + } + w = projectKeyHTTP(h, "POST", owners, "admin", `{}`) + if w.Code != 405 { + t.Fatalf("readonly endpoint %d", w.Code) + } +} + +func TestAuthenticatedWriteProvenance(t *testing.T) { + key := callerBinding() + fixture, _ := NewAuthenticator([]APIKey{key}) + binding, _ := fixture.keys.ResolveProjectAPIKey(t.Context(), key.TokenSHA256) + binding.Key = store.ProjectAPIKey{ID: uuid.NewString(), Name: "SDK", Prefix: "pc_12345678"} + keys := &projectKeyStoreFixture{binding: binding} + auth, _ := NewDatabaseAuthenticator(keys) + h := &Handler{auth: auth} + var source writeaudit.Source + var got bool + handler := agentsResponseHeaders(log.HTTPMiddleware(h.authenticateCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + source, got = writeaudit.FromContext(r.Context()) + w.WriteHeader(204) + }), true))) + for _, test := range []struct { + method, path string + present bool + }{{"POST", "/v1/agents", true}, {"DELETE", "/v1/files/file-one", true}, {"GET", "/v1/agents", false}, {"POST", "/core/v1/anything", false}} { + r := httptest.NewRequest(test.method, test.path, nil) + r.Header.Set("Authorization", "Bearer issued-project-key") + r.Header.Set("X-API-Key-ID", "forged") + r.Header.Set("X-Request-Id", "forged") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != 204 || got != test.present { + t.Fatalf("source eligibility %d %v", w.Code, got) + } + if got && (source.KeyID != binding.Key.ID || source.Kind != "issued" || source.TenantID != key.TenantID || source.RequestID != w.Header().Get("X-Request-Id") || source.RequestID == "forged" || len(source.TraceID) != 32) { + t.Fatalf("source %+v", source) + } + } + keys.resolve = store.ErrNotFound + if w := projectKeyHTTP(handler, "POST", "/v1/agents", "issued-project-key", ""); w.Code != 401 { + t.Fatalf("revoked key %d", w.Code) + } +} + +func TestAuditQueryJSONSafeFields(t *testing.T) { + now := time.Now().UTC() + raw, err := json.Marshal(ResourceOwnerList{Data: []store.ResourceOwner{{ResourceID: "r", APIKey: &store.AuditAPIKey{ID: "key", Name: "sdk", Prefix: "pc_prefix", Kind: "issued", RevokedAt: &now}}}}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"id", "name", "prefix", "kind", "revoked_at"} { + if !strings.Contains(string(raw), `"`+name+`"`) { + t.Fatal(name) + } + } + for _, name := range []string{"token_sha256", "binding_digest", "tenant_id"} { + if strings.Contains(string(raw), name) { + t.Fatal(name) + } + } +} diff --git a/services/agents-api/internal/coremetrics/process.go b/services/core/internal/coremetrics/process.go similarity index 100% rename from services/agents-api/internal/coremetrics/process.go rename to services/core/internal/coremetrics/process.go diff --git a/services/agents-api/internal/coremetrics/process_files.go b/services/core/internal/coremetrics/process_files.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_files.go rename to services/core/internal/coremetrics/process_files.go diff --git a/services/agents-api/internal/coremetrics/process_files_test.go b/services/core/internal/coremetrics/process_files_test.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_files_test.go rename to services/core/internal/coremetrics/process_files_test.go diff --git a/services/agents-api/internal/coremetrics/process_linux.go b/services/core/internal/coremetrics/process_linux.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_linux.go rename to services/core/internal/coremetrics/process_linux.go diff --git a/services/agents-api/internal/coremetrics/process_linux_test.go b/services/core/internal/coremetrics/process_linux_test.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_linux_test.go rename to services/core/internal/coremetrics/process_linux_test.go diff --git a/services/agents-api/internal/coremetrics/process_other.go b/services/core/internal/coremetrics/process_other.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_other.go rename to services/core/internal/coremetrics/process_other.go diff --git a/services/agents-api/internal/coremetrics/process_test.go b/services/core/internal/coremetrics/process_test.go similarity index 100% rename from services/agents-api/internal/coremetrics/process_test.go rename to services/core/internal/coremetrics/process_test.go diff --git a/services/agents-api/internal/coremetrics/series.go b/services/core/internal/coremetrics/series.go similarity index 100% rename from services/agents-api/internal/coremetrics/series.go rename to services/core/internal/coremetrics/series.go diff --git a/services/agents-api/internal/coremetrics/service.go b/services/core/internal/coremetrics/service.go similarity index 100% rename from services/agents-api/internal/coremetrics/service.go rename to services/core/internal/coremetrics/service.go diff --git a/services/agents-api/internal/coremetrics/service_test.go b/services/core/internal/coremetrics/service_test.go similarity index 100% rename from services/agents-api/internal/coremetrics/service_test.go rename to services/core/internal/coremetrics/service_test.go diff --git a/services/agents-api/internal/coremetrics/types.go b/services/core/internal/coremetrics/types.go similarity index 100% rename from services/agents-api/internal/coremetrics/types.go rename to services/core/internal/coremetrics/types.go diff --git a/services/agents-api/internal/credentialcrypto/agent_model_execution.go b/services/core/internal/credentialcrypto/agent_model_execution.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/agent_model_execution.go rename to services/core/internal/credentialcrypto/agent_model_execution.go diff --git a/services/agents-api/internal/credentialcrypto/agent_model_execution_test.go b/services/core/internal/credentialcrypto/agent_model_execution_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/agent_model_execution_test.go rename to services/core/internal/credentialcrypto/agent_model_execution_test.go diff --git a/services/agents-api/internal/credentialcrypto/cipher.go b/services/core/internal/credentialcrypto/cipher.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/cipher.go rename to services/core/internal/credentialcrypto/cipher.go diff --git a/services/agents-api/internal/credentialcrypto/cipher_test.go b/services/core/internal/credentialcrypto/cipher_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/cipher_test.go rename to services/core/internal/credentialcrypto/cipher_test.go diff --git a/services/agents-api/internal/credentialcrypto/deployment_model_provider.go b/services/core/internal/credentialcrypto/deployment_model_provider.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/deployment_model_provider.go rename to services/core/internal/credentialcrypto/deployment_model_provider.go diff --git a/services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go b/services/core/internal/credentialcrypto/deployment_model_provider_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go rename to services/core/internal/credentialcrypto/deployment_model_provider_test.go diff --git a/services/agents-api/internal/credentialcrypto/environment_file.go b/services/core/internal/credentialcrypto/environment_file.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/environment_file.go rename to services/core/internal/credentialcrypto/environment_file.go diff --git a/services/agents-api/internal/credentialcrypto/environment_file_test.go b/services/core/internal/credentialcrypto/environment_file_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/environment_file_test.go rename to services/core/internal/credentialcrypto/environment_file_test.go diff --git a/services/agents-api/internal/credentialcrypto/environment_setup.go b/services/core/internal/credentialcrypto/environment_setup.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/environment_setup.go rename to services/core/internal/credentialcrypto/environment_setup.go diff --git a/services/agents-api/internal/credentialcrypto/environment_setup_test.go b/services/core/internal/credentialcrypto/environment_setup_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/environment_setup_test.go rename to services/core/internal/credentialcrypto/environment_setup_test.go diff --git a/services/agents-api/internal/credentialcrypto/model_execution.go b/services/core/internal/credentialcrypto/model_execution.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/model_execution.go rename to services/core/internal/credentialcrypto/model_execution.go diff --git a/services/agents-api/internal/credentialcrypto/sandbox_deployment.go b/services/core/internal/credentialcrypto/sandbox_deployment.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/sandbox_deployment.go rename to services/core/internal/credentialcrypto/sandbox_deployment.go diff --git a/services/agents-api/internal/credentialcrypto/sandbox_deployment_test.go b/services/core/internal/credentialcrypto/sandbox_deployment_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/sandbox_deployment_test.go rename to services/core/internal/credentialcrypto/sandbox_deployment_test.go diff --git a/services/agents-api/internal/credentialcrypto/skill.go b/services/core/internal/credentialcrypto/skill.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/skill.go rename to services/core/internal/credentialcrypto/skill.go diff --git a/services/agents-api/internal/credentialcrypto/skill_test.go b/services/core/internal/credentialcrypto/skill_test.go similarity index 100% rename from services/agents-api/internal/credentialcrypto/skill_test.go rename to services/core/internal/credentialcrypto/skill_test.go diff --git a/services/agents-api/internal/databaseurl/databaseurl.go b/services/core/internal/databaseurl/databaseurl.go similarity index 100% rename from services/agents-api/internal/databaseurl/databaseurl.go rename to services/core/internal/databaseurl/databaseurl.go diff --git a/services/agents-api/internal/databaseurl/databaseurl_test.go b/services/core/internal/databaseurl/databaseurl_test.go similarity index 100% rename from services/agents-api/internal/databaseurl/databaseurl_test.go rename to services/core/internal/databaseurl/databaseurl_test.go diff --git a/services/agents-api/internal/db/queries/admin_audit.sql b/services/core/internal/db/queries/admin_audit.sql similarity index 100% rename from services/agents-api/internal/db/queries/admin_audit.sql rename to services/core/internal/db/queries/admin_audit.sql diff --git a/services/agents-api/internal/db/queries/admin_history.sql b/services/core/internal/db/queries/admin_history.sql similarity index 100% rename from services/agents-api/internal/db/queries/admin_history.sql rename to services/core/internal/db/queries/admin_history.sql diff --git a/services/agents-api/internal/db/queries/admin_session_archive.sql b/services/core/internal/db/queries/admin_session_archive.sql similarity index 100% rename from services/agents-api/internal/db/queries/admin_session_archive.sql rename to services/core/internal/db/queries/admin_session_archive.sql diff --git a/services/agents-api/internal/db/queries/admin_summary.sql b/services/core/internal/db/queries/admin_summary.sql similarity index 100% rename from services/agents-api/internal/db/queries/admin_summary.sql rename to services/core/internal/db/queries/admin_summary.sql diff --git a/services/agents-api/internal/db/queries/agent_model_execution.sql b/services/core/internal/db/queries/agent_model_execution.sql similarity index 100% rename from services/agents-api/internal/db/queries/agent_model_execution.sql rename to services/core/internal/db/queries/agent_model_execution.sql diff --git a/services/agents-api/internal/db/queries/agents.sql b/services/core/internal/db/queries/agents.sql similarity index 100% rename from services/agents-api/internal/db/queries/agents.sql rename to services/core/internal/db/queries/agents.sql diff --git a/services/agents-api/internal/db/queries/core_metrics.sql b/services/core/internal/db/queries/core_metrics.sql similarity index 100% rename from services/agents-api/internal/db/queries/core_metrics.sql rename to services/core/internal/db/queries/core_metrics.sql diff --git a/services/agents-api/internal/db/queries/deployment_model_providers.sql b/services/core/internal/db/queries/deployment_model_providers.sql similarity index 100% rename from services/agents-api/internal/db/queries/deployment_model_providers.sql rename to services/core/internal/db/queries/deployment_model_providers.sql diff --git a/services/agents-api/internal/db/queries/deployment_provider_observations.sql b/services/core/internal/db/queries/deployment_provider_observations.sql similarity index 100% rename from services/agents-api/internal/db/queries/deployment_provider_observations.sql rename to services/core/internal/db/queries/deployment_provider_observations.sql diff --git a/services/agents-api/internal/db/queries/devices.sql b/services/core/internal/db/queries/devices.sql similarity index 100% rename from services/agents-api/internal/db/queries/devices.sql rename to services/core/internal/db/queries/devices.sql diff --git a/services/agents-api/internal/db/queries/environment_connections.sql b/services/core/internal/db/queries/environment_connections.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_connections.sql rename to services/core/internal/db/queries/environment_connections.sql diff --git a/services/agents-api/internal/db/queries/environment_executor_credentials.sql b/services/core/internal/db/queries/environment_executor_credentials.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_executor_credentials.sql rename to services/core/internal/db/queries/environment_executor_credentials.sql diff --git a/services/agents-api/internal/db/queries/environment_file_writes.sql b/services/core/internal/db/queries/environment_file_writes.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_file_writes.sql rename to services/core/internal/db/queries/environment_file_writes.sql diff --git a/services/agents-api/internal/db/queries/environment_initialization.sql b/services/core/internal/db/queries/environment_initialization.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_initialization.sql rename to services/core/internal/db/queries/environment_initialization.sql diff --git a/services/agents-api/internal/db/queries/environment_input_activity.sql b/services/core/internal/db/queries/environment_input_activity.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_input_activity.sql rename to services/core/internal/db/queries/environment_input_activity.sql diff --git a/services/agents-api/internal/db/queries/environment_input_expiry.sql b/services/core/internal/db/queries/environment_input_expiry.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_input_expiry.sql rename to services/core/internal/db/queries/environment_input_expiry.sql diff --git a/services/agents-api/internal/db/queries/environment_inputs.sql b/services/core/internal/db/queries/environment_inputs.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_inputs.sql rename to services/core/internal/db/queries/environment_inputs.sql diff --git a/services/agents-api/internal/db/queries/environment_setup.sql b/services/core/internal/db/queries/environment_setup.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_setup.sql rename to services/core/internal/db/queries/environment_setup.sql diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/core/internal/db/queries/environment_templates.sql similarity index 100% rename from services/agents-api/internal/db/queries/environment_templates.sql rename to services/core/internal/db/queries/environment_templates.sql diff --git a/services/agents-api/internal/db/queries/environments.sql b/services/core/internal/db/queries/environments.sql similarity index 100% rename from services/agents-api/internal/db/queries/environments.sql rename to services/core/internal/db/queries/environments.sql diff --git a/services/agents-api/internal/db/queries/functions.sql b/services/core/internal/db/queries/functions.sql similarity index 100% rename from services/agents-api/internal/db/queries/functions.sql rename to services/core/internal/db/queries/functions.sql diff --git a/services/agents-api/internal/db/queries/initial_environment_files.sql b/services/core/internal/db/queries/initial_environment_files.sql similarity index 100% rename from services/agents-api/internal/db/queries/initial_environment_files.sql rename to services/core/internal/db/queries/initial_environment_files.sql diff --git a/services/agents-api/internal/db/queries/local_environment_devices.sql b/services/core/internal/db/queries/local_environment_devices.sql similarity index 100% rename from services/agents-api/internal/db/queries/local_environment_devices.sql rename to services/core/internal/db/queries/local_environment_devices.sql diff --git a/services/agents-api/internal/db/queries/mcp_credentials.sql b/services/core/internal/db/queries/mcp_credentials.sql similarity index 100% rename from services/agents-api/internal/db/queries/mcp_credentials.sql rename to services/core/internal/db/queries/mcp_credentials.sql diff --git a/services/agents-api/internal/db/queries/node_generations.sql b/services/core/internal/db/queries/node_generations.sql similarity index 100% rename from services/agents-api/internal/db/queries/node_generations.sql rename to services/core/internal/db/queries/node_generations.sql diff --git a/services/agents-api/internal/db/queries/node_host_history.sql b/services/core/internal/db/queries/node_host_history.sql similarity index 100% rename from services/agents-api/internal/db/queries/node_host_history.sql rename to services/core/internal/db/queries/node_host_history.sql diff --git a/services/agents-api/internal/db/queries/oauth_credentials.sql b/services/core/internal/db/queries/oauth_credentials.sql similarity index 100% rename from services/agents-api/internal/db/queries/oauth_credentials.sql rename to services/core/internal/db/queries/oauth_credentials.sql diff --git a/services/agents-api/internal/db/queries/project_api_keys.sql b/services/core/internal/db/queries/project_api_keys.sql similarity index 100% rename from services/agents-api/internal/db/queries/project_api_keys.sql rename to services/core/internal/db/queries/project_api_keys.sql diff --git a/services/agents-api/internal/db/queries/project_scopes.sql b/services/core/internal/db/queries/project_scopes.sql similarity index 100% rename from services/agents-api/internal/db/queries/project_scopes.sql rename to services/core/internal/db/queries/project_scopes.sql diff --git a/services/agents-api/internal/db/queries/projects.sql b/services/core/internal/db/queries/projects.sql similarity index 100% rename from services/agents-api/internal/db/queries/projects.sql rename to services/core/internal/db/queries/projects.sql diff --git a/services/agents-api/internal/db/queries/runtime_allocations.sql b/services/core/internal/db/queries/runtime_allocations.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_allocations.sql rename to services/core/internal/db/queries/runtime_allocations.sql diff --git a/services/agents-api/internal/db/queries/runtime_cancellation.sql b/services/core/internal/db/queries/runtime_cancellation.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_cancellation.sql rename to services/core/internal/db/queries/runtime_cancellation.sql diff --git a/services/agents-api/internal/db/queries/runtime_deployment.sql b/services/core/internal/db/queries/runtime_deployment.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_deployment.sql rename to services/core/internal/db/queries/runtime_deployment.sql diff --git a/services/agents-api/internal/db/queries/runtime_enrollment.sql b/services/core/internal/db/queries/runtime_enrollment.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_enrollment.sql rename to services/core/internal/db/queries/runtime_enrollment.sql diff --git a/services/agents-api/internal/db/queries/runtime_history.sql b/services/core/internal/db/queries/runtime_history.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_history.sql rename to services/core/internal/db/queries/runtime_history.sql diff --git a/services/agents-api/internal/db/queries/runtime_lifecycle_nodes.sql b/services/core/internal/db/queries/runtime_lifecycle_nodes.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_lifecycle_nodes.sql rename to services/core/internal/db/queries/runtime_lifecycle_nodes.sql diff --git a/services/agents-api/internal/db/queries/runtime_nodes.sql b/services/core/internal/db/queries/runtime_nodes.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_nodes.sql rename to services/core/internal/db/queries/runtime_nodes.sql diff --git a/services/agents-api/internal/db/queries/runtime_suspension.sql b/services/core/internal/db/queries/runtime_suspension.sql similarity index 100% rename from services/agents-api/internal/db/queries/runtime_suspension.sql rename to services/core/internal/db/queries/runtime_suspension.sql diff --git a/services/agents-api/internal/db/queries/sandbox_deployment_setup.sql b/services/core/internal/db/queries/sandbox_deployment_setup.sql similarity index 100% rename from services/agents-api/internal/db/queries/sandbox_deployment_setup.sql rename to services/core/internal/db/queries/sandbox_deployment_setup.sql diff --git a/services/agents-api/internal/db/queries/sandbox_generations.sql b/services/core/internal/db/queries/sandbox_generations.sql similarity index 100% rename from services/agents-api/internal/db/queries/sandbox_generations.sql rename to services/core/internal/db/queries/sandbox_generations.sql diff --git a/services/agents-api/internal/db/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql similarity index 100% rename from services/agents-api/internal/db/queries/sandbox_reset.sql rename to services/core/internal/db/queries/sandbox_reset.sql diff --git a/services/agents-api/internal/db/queries/scheduling.sql b/services/core/internal/db/queries/scheduling.sql similarity index 100% rename from services/agents-api/internal/db/queries/scheduling.sql rename to services/core/internal/db/queries/scheduling.sql diff --git a/services/agents-api/internal/db/queries/session_artifacts.sql b/services/core/internal/db/queries/session_artifacts.sql similarity index 100% rename from services/agents-api/internal/db/queries/session_artifacts.sql rename to services/core/internal/db/queries/session_artifacts.sql diff --git a/services/agents-api/internal/db/queries/session_events.sql b/services/core/internal/db/queries/session_events.sql similarity index 100% rename from services/agents-api/internal/db/queries/session_events.sql rename to services/core/internal/db/queries/session_events.sql diff --git a/services/agents-api/internal/db/queries/session_execution_configuration.sql b/services/core/internal/db/queries/session_execution_configuration.sql similarity index 100% rename from services/agents-api/internal/db/queries/session_execution_configuration.sql rename to services/core/internal/db/queries/session_execution_configuration.sql diff --git a/services/agents-api/internal/db/queries/session_items.sql b/services/core/internal/db/queries/session_items.sql similarity index 100% rename from services/agents-api/internal/db/queries/session_items.sql rename to services/core/internal/db/queries/session_items.sql diff --git a/services/agents-api/internal/db/queries/session_model_execution.sql b/services/core/internal/db/queries/session_model_execution.sql similarity index 100% rename from services/agents-api/internal/db/queries/session_model_execution.sql rename to services/core/internal/db/queries/session_model_execution.sql diff --git a/services/agents-api/internal/db/queries/sessions.sql b/services/core/internal/db/queries/sessions.sql similarity index 100% rename from services/agents-api/internal/db/queries/sessions.sql rename to services/core/internal/db/queries/sessions.sql diff --git a/services/agents-api/internal/db/queries/skills.sql b/services/core/internal/db/queries/skills.sql similarity index 100% rename from services/agents-api/internal/db/queries/skills.sql rename to services/core/internal/db/queries/skills.sql diff --git a/services/agents-api/internal/db/queries/source_files.sql b/services/core/internal/db/queries/source_files.sql similarity index 100% rename from services/agents-api/internal/db/queries/source_files.sql rename to services/core/internal/db/queries/source_files.sql diff --git a/services/agents-api/internal/db/queries/subagent_identities.sql b/services/core/internal/db/queries/subagent_identities.sql similarity index 100% rename from services/agents-api/internal/db/queries/subagent_identities.sql rename to services/core/internal/db/queries/subagent_identities.sql diff --git a/services/agents-api/internal/db/queries/subagent_resources.sql b/services/core/internal/db/queries/subagent_resources.sql similarity index 100% rename from services/agents-api/internal/db/queries/subagent_resources.sql rename to services/core/internal/db/queries/subagent_resources.sql diff --git a/services/agents-api/internal/db/queries/token_usage.sql b/services/core/internal/db/queries/token_usage.sql similarity index 100% rename from services/agents-api/internal/db/queries/token_usage.sql rename to services/core/internal/db/queries/token_usage.sql diff --git a/services/agents-api/internal/db/queries/turn_events.sql b/services/core/internal/db/queries/turn_events.sql similarity index 100% rename from services/agents-api/internal/db/queries/turn_events.sql rename to services/core/internal/db/queries/turn_events.sql diff --git a/services/agents-api/internal/db/queries/turn_reads.sql b/services/core/internal/db/queries/turn_reads.sql similarity index 100% rename from services/agents-api/internal/db/queries/turn_reads.sql rename to services/core/internal/db/queries/turn_reads.sql diff --git a/services/agents-api/internal/db/queries/turns.sql b/services/core/internal/db/queries/turns.sql similarity index 100% rename from services/agents-api/internal/db/queries/turns.sql rename to services/core/internal/db/queries/turns.sql diff --git a/services/agents-api/internal/db/queries/vault_credentials.sql b/services/core/internal/db/queries/vault_credentials.sql similarity index 100% rename from services/agents-api/internal/db/queries/vault_credentials.sql rename to services/core/internal/db/queries/vault_credentials.sql diff --git a/services/agents-api/internal/db/queries/vaults.sql b/services/core/internal/db/queries/vaults.sql similarity index 100% rename from services/agents-api/internal/db/queries/vaults.sql rename to services/core/internal/db/queries/vaults.sql diff --git a/services/agents-api/internal/db/queries/write_audit.sql b/services/core/internal/db/queries/write_audit.sql similarity index 100% rename from services/agents-api/internal/db/queries/write_audit.sql rename to services/core/internal/db/queries/write_audit.sql diff --git a/services/agents-api/internal/db/sqlc/admin_audit.sql.go b/services/core/internal/db/sqlc/admin_audit.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/admin_audit.sql.go rename to services/core/internal/db/sqlc/admin_audit.sql.go diff --git a/services/agents-api/internal/db/sqlc/admin_history.sql.go b/services/core/internal/db/sqlc/admin_history.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/admin_history.sql.go rename to services/core/internal/db/sqlc/admin_history.sql.go diff --git a/services/agents-api/internal/db/sqlc/admin_session_archive.sql.go b/services/core/internal/db/sqlc/admin_session_archive.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/admin_session_archive.sql.go rename to services/core/internal/db/sqlc/admin_session_archive.sql.go diff --git a/services/agents-api/internal/db/sqlc/admin_summary.sql.go b/services/core/internal/db/sqlc/admin_summary.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/admin_summary.sql.go rename to services/core/internal/db/sqlc/admin_summary.sql.go diff --git a/services/agents-api/internal/db/sqlc/agent_model_execution.sql.go b/services/core/internal/db/sqlc/agent_model_execution.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/agent_model_execution.sql.go rename to services/core/internal/db/sqlc/agent_model_execution.sql.go diff --git a/services/agents-api/internal/db/sqlc/agents.sql.go b/services/core/internal/db/sqlc/agents.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/agents.sql.go rename to services/core/internal/db/sqlc/agents.sql.go diff --git a/services/agents-api/internal/db/sqlc/core_metrics.sql.go b/services/core/internal/db/sqlc/core_metrics.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/core_metrics.sql.go rename to services/core/internal/db/sqlc/core_metrics.sql.go diff --git a/services/agents-api/internal/db/sqlc/db.go b/services/core/internal/db/sqlc/db.go similarity index 100% rename from services/agents-api/internal/db/sqlc/db.go rename to services/core/internal/db/sqlc/db.go diff --git a/services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go b/services/core/internal/db/sqlc/deployment_model_providers.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go rename to services/core/internal/db/sqlc/deployment_model_providers.sql.go diff --git a/services/agents-api/internal/db/sqlc/deployment_provider_observations.sql.go b/services/core/internal/db/sqlc/deployment_provider_observations.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/deployment_provider_observations.sql.go rename to services/core/internal/db/sqlc/deployment_provider_observations.sql.go diff --git a/services/agents-api/internal/db/sqlc/devices.sql.go b/services/core/internal/db/sqlc/devices.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/devices.sql.go rename to services/core/internal/db/sqlc/devices.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_connections.sql.go b/services/core/internal/db/sqlc/environment_connections.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_connections.sql.go rename to services/core/internal/db/sqlc/environment_connections.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go b/services/core/internal/db/sqlc/environment_executor_credentials.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go rename to services/core/internal/db/sqlc/environment_executor_credentials.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go b/services/core/internal/db/sqlc/environment_file_writes.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_file_writes.sql.go rename to services/core/internal/db/sqlc/environment_file_writes.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_initialization.sql.go b/services/core/internal/db/sqlc/environment_initialization.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_initialization.sql.go rename to services/core/internal/db/sqlc/environment_initialization.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go b/services/core/internal/db/sqlc/environment_input_activity.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_input_activity.sql.go rename to services/core/internal/db/sqlc/environment_input_activity.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go b/services/core/internal/db/sqlc/environment_input_expiry.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_input_expiry.sql.go rename to services/core/internal/db/sqlc/environment_input_expiry.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_inputs.sql.go b/services/core/internal/db/sqlc/environment_inputs.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_inputs.sql.go rename to services/core/internal/db/sqlc/environment_inputs.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_setup.sql.go b/services/core/internal/db/sqlc/environment_setup.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_setup.sql.go rename to services/core/internal/db/sqlc/environment_setup.sql.go diff --git a/services/agents-api/internal/db/sqlc/environment_templates.sql.go b/services/core/internal/db/sqlc/environment_templates.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environment_templates.sql.go rename to services/core/internal/db/sqlc/environment_templates.sql.go diff --git a/services/agents-api/internal/db/sqlc/environments.sql.go b/services/core/internal/db/sqlc/environments.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/environments.sql.go rename to services/core/internal/db/sqlc/environments.sql.go diff --git a/services/agents-api/internal/db/sqlc/functions.sql.go b/services/core/internal/db/sqlc/functions.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/functions.sql.go rename to services/core/internal/db/sqlc/functions.sql.go diff --git a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go b/services/core/internal/db/sqlc/initial_environment_files.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/initial_environment_files.sql.go rename to services/core/internal/db/sqlc/initial_environment_files.sql.go diff --git a/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go b/services/core/internal/db/sqlc/local_environment_devices.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/local_environment_devices.sql.go rename to services/core/internal/db/sqlc/local_environment_devices.sql.go diff --git a/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go b/services/core/internal/db/sqlc/mcp_credentials.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/mcp_credentials.sql.go rename to services/core/internal/db/sqlc/mcp_credentials.sql.go diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go similarity index 100% rename from services/agents-api/internal/db/sqlc/models.go rename to services/core/internal/db/sqlc/models.go diff --git a/services/agents-api/internal/db/sqlc/node_generations.sql.go b/services/core/internal/db/sqlc/node_generations.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/node_generations.sql.go rename to services/core/internal/db/sqlc/node_generations.sql.go diff --git a/services/agents-api/internal/db/sqlc/node_host_history.sql.go b/services/core/internal/db/sqlc/node_host_history.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/node_host_history.sql.go rename to services/core/internal/db/sqlc/node_host_history.sql.go diff --git a/services/agents-api/internal/db/sqlc/oauth_credentials.sql.go b/services/core/internal/db/sqlc/oauth_credentials.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/oauth_credentials.sql.go rename to services/core/internal/db/sqlc/oauth_credentials.sql.go diff --git a/services/agents-api/internal/db/sqlc/project_api_keys.sql.go b/services/core/internal/db/sqlc/project_api_keys.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/project_api_keys.sql.go rename to services/core/internal/db/sqlc/project_api_keys.sql.go diff --git a/services/agents-api/internal/db/sqlc/project_scopes.sql.go b/services/core/internal/db/sqlc/project_scopes.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/project_scopes.sql.go rename to services/core/internal/db/sqlc/project_scopes.sql.go diff --git a/services/agents-api/internal/db/sqlc/projects.sql.go b/services/core/internal/db/sqlc/projects.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/projects.sql.go rename to services/core/internal/db/sqlc/projects.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go b/services/core/internal/db/sqlc/runtime_allocations.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_allocations.sql.go rename to services/core/internal/db/sqlc/runtime_allocations.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_cancellation.sql.go b/services/core/internal/db/sqlc/runtime_cancellation.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_cancellation.sql.go rename to services/core/internal/db/sqlc/runtime_cancellation.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_deployment.sql.go b/services/core/internal/db/sqlc/runtime_deployment.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_deployment.sql.go rename to services/core/internal/db/sqlc/runtime_deployment.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_enrollment.sql.go b/services/core/internal/db/sqlc/runtime_enrollment.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_enrollment.sql.go rename to services/core/internal/db/sqlc/runtime_enrollment.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_history.sql.go b/services/core/internal/db/sqlc/runtime_history.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_history.sql.go rename to services/core/internal/db/sqlc/runtime_history.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go b/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go rename to services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_nodes.sql.go rename to services/core/internal/db/sqlc/runtime_nodes.sql.go diff --git a/services/agents-api/internal/db/sqlc/runtime_suspension.sql.go b/services/core/internal/db/sqlc/runtime_suspension.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/runtime_suspension.sql.go rename to services/core/internal/db/sqlc/runtime_suspension.sql.go diff --git a/services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/sandbox_deployment_setup.sql.go rename to services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go diff --git a/services/agents-api/internal/db/sqlc/sandbox_generations.sql.go b/services/core/internal/db/sqlc/sandbox_generations.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/sandbox_generations.sql.go rename to services/core/internal/db/sqlc/sandbox_generations.sql.go diff --git a/services/agents-api/internal/db/sqlc/sandbox_reset.sql.go b/services/core/internal/db/sqlc/sandbox_reset.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/sandbox_reset.sql.go rename to services/core/internal/db/sqlc/sandbox_reset.sql.go diff --git a/services/agents-api/internal/db/sqlc/scheduling.sql.go b/services/core/internal/db/sqlc/scheduling.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/scheduling.sql.go rename to services/core/internal/db/sqlc/scheduling.sql.go diff --git a/services/agents-api/internal/db/sqlc/session_artifacts.sql.go b/services/core/internal/db/sqlc/session_artifacts.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/session_artifacts.sql.go rename to services/core/internal/db/sqlc/session_artifacts.sql.go diff --git a/services/agents-api/internal/db/sqlc/session_events.sql.go b/services/core/internal/db/sqlc/session_events.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/session_events.sql.go rename to services/core/internal/db/sqlc/session_events.sql.go diff --git a/services/agents-api/internal/db/sqlc/session_execution_configuration.sql.go b/services/core/internal/db/sqlc/session_execution_configuration.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/session_execution_configuration.sql.go rename to services/core/internal/db/sqlc/session_execution_configuration.sql.go diff --git a/services/agents-api/internal/db/sqlc/session_items.sql.go b/services/core/internal/db/sqlc/session_items.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/session_items.sql.go rename to services/core/internal/db/sqlc/session_items.sql.go diff --git a/services/agents-api/internal/db/sqlc/session_model_execution.sql.go b/services/core/internal/db/sqlc/session_model_execution.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/session_model_execution.sql.go rename to services/core/internal/db/sqlc/session_model_execution.sql.go diff --git a/services/agents-api/internal/db/sqlc/sessions.sql.go b/services/core/internal/db/sqlc/sessions.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/sessions.sql.go rename to services/core/internal/db/sqlc/sessions.sql.go diff --git a/services/agents-api/internal/db/sqlc/skills.sql.go b/services/core/internal/db/sqlc/skills.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/skills.sql.go rename to services/core/internal/db/sqlc/skills.sql.go diff --git a/services/agents-api/internal/db/sqlc/source_files.sql.go b/services/core/internal/db/sqlc/source_files.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/source_files.sql.go rename to services/core/internal/db/sqlc/source_files.sql.go diff --git a/services/agents-api/internal/db/sqlc/subagent_identities.sql.go b/services/core/internal/db/sqlc/subagent_identities.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/subagent_identities.sql.go rename to services/core/internal/db/sqlc/subagent_identities.sql.go diff --git a/services/agents-api/internal/db/sqlc/subagent_resources.sql.go b/services/core/internal/db/sqlc/subagent_resources.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/subagent_resources.sql.go rename to services/core/internal/db/sqlc/subagent_resources.sql.go diff --git a/services/agents-api/internal/db/sqlc/token_usage.sql.go b/services/core/internal/db/sqlc/token_usage.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/token_usage.sql.go rename to services/core/internal/db/sqlc/token_usage.sql.go diff --git a/services/agents-api/internal/db/sqlc/turn_events.sql.go b/services/core/internal/db/sqlc/turn_events.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/turn_events.sql.go rename to services/core/internal/db/sqlc/turn_events.sql.go diff --git a/services/agents-api/internal/db/sqlc/turn_reads.sql.go b/services/core/internal/db/sqlc/turn_reads.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/turn_reads.sql.go rename to services/core/internal/db/sqlc/turn_reads.sql.go diff --git a/services/agents-api/internal/db/sqlc/turns.sql.go b/services/core/internal/db/sqlc/turns.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/turns.sql.go rename to services/core/internal/db/sqlc/turns.sql.go diff --git a/services/agents-api/internal/db/sqlc/vault_credentials.sql.go b/services/core/internal/db/sqlc/vault_credentials.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/vault_credentials.sql.go rename to services/core/internal/db/sqlc/vault_credentials.sql.go diff --git a/services/agents-api/internal/db/sqlc/vaults.sql.go b/services/core/internal/db/sqlc/vaults.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/vaults.sql.go rename to services/core/internal/db/sqlc/vaults.sql.go diff --git a/services/agents-api/internal/db/sqlc/write_audit.sql.go b/services/core/internal/db/sqlc/write_audit.sql.go similarity index 100% rename from services/agents-api/internal/db/sqlc/write_audit.sql.go rename to services/core/internal/db/sqlc/write_audit.sql.go diff --git a/services/agents-api/internal/echotext/echotext.go b/services/core/internal/echotext/echotext.go similarity index 100% rename from services/agents-api/internal/echotext/echotext.go rename to services/core/internal/echotext/echotext.go diff --git a/services/agents-api/internal/echotext/echotext_test.go b/services/core/internal/echotext/echotext_test.go similarity index 100% rename from services/agents-api/internal/echotext/echotext_test.go rename to services/core/internal/echotext/echotext_test.go diff --git a/services/agents-api/internal/engine/catalog_generated.go b/services/core/internal/engine/catalog_generated.go similarity index 100% rename from services/agents-api/internal/engine/catalog_generated.go rename to services/core/internal/engine/catalog_generated.go diff --git a/services/agents-api/internal/engine/claude.go b/services/core/internal/engine/claude.go similarity index 96% rename from services/agents-api/internal/engine/claude.go rename to services/core/internal/engine/claude.go index c7ffca9a2..9eb99c198 100644 --- a/services/agents-api/internal/engine/claude.go +++ b/services/core/internal/engine/claude.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // WhitespaceOnlyText stays unqualified: the bridge and Anthropic-compatible diff --git a/services/agents-api/internal/engine/claude_mcp.go b/services/core/internal/engine/claude_mcp.go similarity index 93% rename from services/agents-api/internal/engine/claude_mcp.go rename to services/core/internal/engine/claude_mcp.go index a65125a55..29c0d84a4 100644 --- a/services/agents-api/internal/engine/claude_mcp.go +++ b/services/core/internal/engine/claude_mcp.go @@ -5,7 +5,7 @@ import ( "regexp" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) var claudeMCPLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) diff --git a/services/agents-api/internal/engine/codex.go b/services/core/internal/engine/codex.go similarity index 89% rename from services/agents-api/internal/engine/codex.go rename to services/core/internal/engine/codex.go index 399a99219..8ce736a37 100644 --- a/services/agents-api/internal/engine/codex.go +++ b/services/core/internal/engine/codex.go @@ -1,7 +1,7 @@ package engine import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) func codexProfile() Profile { diff --git a/services/core/internal/engine/configuration_test.go b/services/core/internal/engine/configuration_test.go new file mode 100644 index 000000000..61df5799d --- /dev/null +++ b/services/core/internal/engine/configuration_test.go @@ -0,0 +1,39 @@ +package engine + +import ( + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" +) + +func TestProviderDeclarationsAgreeWithAdmission(t *testing.T) { + for _, kind := range (Catalog{}).Kinds() { + t.Run(kind, func(t *testing.T) { + declared, ok := builtin.Registry().Lookup(kind) + if !ok { + t.Fatal("qualified harness has no provider declaration") + } + for _, protocol := range []string{"responses", "anthropic", "unknown"} { + provider, supported := declared.Provider(protocol) + input := v1.ModelProviderInput{Protocol: protocol, BaseURL: "https://example.test", APIKey: "private-fixture", ContextWindow: 100, MaxOutputTokens: 20} + if (input.ValidateHarness(kind) == nil) != supported || (v1.ValidateModelProtocol(protocol, kind) == nil) != supported { + t.Fatalf("protocol %q disagrees with validation", protocol) + } + if !supported { + continue + } + input.ContextWindow, input.MaxOutputTokens = 0, 0 + if (input.ValidateHarness(kind) != nil) != provider.RequiresTokenLimits { + t.Fatalf("token requirements disagree for %q", protocol) + } + if provider.RequiresTokenLimits { + input.ContextWindow = 100 + if input.ValidateHarness(kind) == nil { + t.Fatal("missing output limit accepted") + } + } + } + }) + } +} diff --git a/services/core/internal/engine/mcode.go b/services/core/internal/engine/mcode.go new file mode 100644 index 000000000..6b6e851a7 --- /dev/null +++ b/services/core/internal/engine/mcode.go @@ -0,0 +1,30 @@ +package engine + +import ( + "errors" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// WhitespaceOnlyText stays unqualified: the native runtime refuses such prompts +// with "Local message content or attachments are required.". +func mcodeProfile() Profile { + return Profile{MCPOrigins: []string{"environment"}, MCPBearer: true, ProgrammaticToolCallingDisable: true, Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { + if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { + return ErrInvalidInput + } + return rejectSubagentTools(a, "mcp") + }, ValidateTools: func(_ *v1.Environment, _ bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if len(functions) > 0 { + return errors.New("The configured engine does not support public functions.") + } + for _, server := range mcp { + if server.ServerLabel == "oac_workspace" || server.AllowedTools != nil || server.Required { + return errors.New("The configured engine requires an unreserved MCP label, allowed_tools=null and required=false.") + } + } + return nil + }} +} diff --git a/services/core/internal/engine/mcp.go b/services/core/internal/engine/mcp.go new file mode 100644 index 000000000..89939ec40 --- /dev/null +++ b/services/core/internal/engine/mcp.go @@ -0,0 +1,32 @@ +package engine + +import ( + "errors" + "slices" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// ValidateMCPOrigins preserves outbound authority independently of Harness names. +// Workspace connections never stand in for service-network connections. +func (p Profile) ValidateMCPOrigins(environment *v1.Environment, hasDaemon bool, servers []proto.MCPHTTPServer) error { + for _, server := range servers { + switch server.ConnectionOrigin { + case "service": + if environment == nil || environment.Type != "none" || hasDaemon { + return errors.New("Service-origin MCP requires the service-side environment:none profile.") + } + case "environment": + if environment == nil || (environment.Type != "openai_hosted" && environment.Type != "self_hosted") || hasDaemon { + return errors.New("Environment-origin MCP requires a managed or self-hosted execution Environment.") + } + default: + return errors.New("MCP requires an explicit connection origin.") + } + if !slices.Contains(p.MCPOrigins, server.ConnectionOrigin) { + return errors.New("The configured engine does not support this MCP connection origin.") + } + } + return nil +} diff --git a/services/core/internal/engine/mcp_test.go b/services/core/internal/engine/mcp_test.go new file mode 100644 index 000000000..e5e0b9801 --- /dev/null +++ b/services/core/internal/engine/mcp_test.go @@ -0,0 +1,56 @@ +package engine + +import ( + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "testing" +) + +func TestMCPOriginQualification(t *testing.T) { + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + profile, ok := (Catalog{}).Lookup(kind) + if !ok { + t.Fatal(kind) + } + for _, placement := range []string{"none", "self_hosted", "openai_hosted"} { + for _, origin := range []string{"service", "environment", "", "unknown"} { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: origin, ServerLabel: "proof", ServerURL: "https://example.test/mcp"}} + allowed := origin == "environment" && placement != "none" || origin == "service" && placement == "none" && kind != "mcode" + if err := profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, false, servers); (err == nil) != allowed { + t.Fatalf("%s/%s/%s: %v", kind, placement, origin, err) + } + if profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, true, servers) == nil { + t.Fatal("legacy daemon placement admitted") + } + } + } + } +} +func TestMiniMaxMCPPoliciesRejectInsteadOfDropping(t *testing.T) { + p, _ := (Catalog{}).Lookup("mcode") + empty := []string{} + named := []string{"proof"} + for _, allowed := range []*[]string{nil, &empty, &named} { + for _, required := range []bool{false, true} { + server := proto.MCPHTTPServer{ConnectionOrigin: "environment", ServerLabel: "proof", ServerURL: "https://example.test", AllowedTools: allowed, Required: required} + err := p.ValidateTools(&v1.Environment{Type: "self_hosted"}, false, nil, []proto.MCPHTTPServer{server}) + if (err == nil) != (allowed == nil && !required) { + t.Fatal("unsupported MCP policy accepted", err) + } + } + } +} +func TestMCPOriginCatalogIsImmutable(t *testing.T) { + p := Profile{MCPOrigins: []string{"environment"}} + c := NewCatalog(map[string]Profile{"fixture": p}) + p.MCPOrigins[0] = "service" + first, _ := c.Lookup("fixture") + if first.MCPOrigins[0] != "environment" { + t.Fatal("mutable source") + } + first.MCPOrigins[0] = "service" + second, _ := c.Lookup("fixture") + if second.MCPOrigins[0] != "environment" { + t.Fatal("mutable lookup") + } +} diff --git a/services/agents-api/internal/engine/profile.go b/services/core/internal/engine/profile.go similarity index 94% rename from services/agents-api/internal/engine/profile.go rename to services/core/internal/engine/profile.go index 223d1a6bb..aa678ef22 100644 --- a/services/agents-api/internal/engine/profile.go +++ b/services/core/internal/engine/profile.go @@ -5,8 +5,8 @@ import ( "maps" "slices" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) var ErrInvalidInput = errors.New("invalid engine configuration") diff --git a/services/agents-api/internal/engine/profile_test.go b/services/core/internal/engine/profile_test.go similarity index 100% rename from services/agents-api/internal/engine/profile_test.go rename to services/core/internal/engine/profile_test.go diff --git a/services/core/internal/engine/subagents.go b/services/core/internal/engine/subagents.go new file mode 100644 index 000000000..882acb90a --- /dev/null +++ b/services/core/internal/engine/subagents.go @@ -0,0 +1,28 @@ +package engine + +import ( + "encoding/json" + "errors" + "slices" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// rejectSubagentTools keeps unqualified native combinations in adapter profiles. +func rejectSubagentTools(agent v1.Agent, unsupported ...string) error { + if !agent.MultiAgent.Enabled { + return nil + } + for _, raw := range agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return ErrInvalidInput + } + if slices.Contains(unsupported, tool.Type) { + return errors.New("The configured Subagent profile does not support this tool combination.") + } + } + return nil +} diff --git a/services/core/internal/engine/subagents_test.go b/services/core/internal/engine/subagents_test.go new file mode 100644 index 000000000..0be335d42 --- /dev/null +++ b/services/core/internal/engine/subagents_test.go @@ -0,0 +1,42 @@ +package engine + +import ( + "encoding/json" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func TestSubagentProfilesPreserveQualifiedOperationBoundaries(t *testing.T) { + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + t.Run(kind, func(t *testing.T) { + profile, ok := (Catalog{}).Lookup(kind) + if !ok { + t.Fatal("profile missing") + } + agent := v1.Agent{Model: "real-model"} + agent.MultiAgent.Enabled = true + for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: placement}, false); err != nil { + t.Fatal(placement, err) + } + } + // Public functions and MCP remain independently qualified capabilities; new + // child observation support does not automatically admit their combinations. + if kind == "mcode" { + return + } // Its existing tool profile rejects both for every Session. + for _, tool := range []string{`{"type":"function","name":"f"}`, `{"type":"mcp","server_label":"s"}`} { + agent.Tools = []json.RawMessage{json.RawMessage(tool)} + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err == nil { + t.Fatal("unqualified multi-agent combination accepted", tool) + } + agent.MultiAgent.Enabled = false + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err != nil { + t.Fatal("single-agent profile changed", err) + } + agent.MultiAgent.Enabled = true + } + }) + } +} diff --git a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go b/services/core/internal/execution/archive_cancellation_cleanup_test.go similarity index 90% rename from services/agents-api/internal/execution/archive_cancellation_cleanup_test.go rename to services/core/internal/execution/archive_cancellation_cleanup_test.go index 7b4ebfde1..16b57498b 100644 --- a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/core/internal/execution/archive_cancellation_cleanup_test.go @@ -3,23 +3,23 @@ package execution import ( "context" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" "net/http" "net/http/httptest" "net/url" "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - runtimegateway "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + runtimegateway "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/execution/artifacts.go b/services/core/internal/execution/artifacts.go similarity index 88% rename from services/agents-api/internal/execution/artifacts.go rename to services/core/internal/execution/artifacts.go index 187f7d4b0..1ff9cacad 100644 --- a/services/agents-api/internal/execution/artifacts.go +++ b/services/core/internal/execution/artifacts.go @@ -6,9 +6,9 @@ import ( "io" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (d *Dispatcher) captureCompletedArtifacts(ctx context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, turnID string, result Result, status string) (Result, string) { diff --git a/services/agents-api/internal/execution/blank_text_test.go b/services/core/internal/execution/blank_text_test.go similarity index 93% rename from services/agents-api/internal/execution/blank_text_test.go rename to services/core/internal/execution/blank_text_test.go index b4883ee4d..05587b054 100644 --- a/services/agents-api/internal/execution/blank_text_test.go +++ b/services/core/internal/execution/blank_text_test.go @@ -9,8 +9,8 @@ import ( "testing" "unicode" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" ) // The Claude bridge test reads the same table, so both sides must agree on diff --git a/services/agents-api/internal/execution/delivery.go b/services/core/internal/execution/delivery.go similarity index 98% rename from services/agents-api/internal/execution/delivery.go rename to services/core/internal/execution/delivery.go index d2f2f1872..c95768983 100644 --- a/services/agents-api/internal/execution/delivery.go +++ b/services/core/internal/execution/delivery.go @@ -7,9 +7,9 @@ import ( "strconv" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type pendingInput struct { diff --git a/services/core/internal/execution/deployment_provider_observations.go b/services/core/internal/execution/deployment_provider_observations.go new file mode 100644 index 000000000..458c38f9c --- /dev/null +++ b/services/core/internal/execution/deployment_provider_observations.go @@ -0,0 +1,34 @@ +package execution + +import ( + "context" + "encoding/json" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// Observation is strictly after terminal commit. Its pool/lock timeout cannot +// cancel the execution lease or change the already committed public outcome. +func (d *Dispatcher) observeDeploymentProvider(tenantID, sessionID string, turn store.Turn) { + if turn.Status != store.TurnCompleted { + if turn.Status != store.TurnFailed { + return + } + var result Result + if json.Unmarshal(turn.Outcome, &result) != nil || result.ErrorCode != "engine_failed" { + return + } + code, _ := proto.NormalizeEngineFailure(result.EngineErrorCode, nil) + if code == "" || code == "context_length_exceeded" || code == "cyber_policy" { + return + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := d.Store.ObserveDeploymentModelProvider(ctx, tenantID, sessionID, turn.ID); err != nil { + log.Warn(ctx, "Deployment model provider observation unavailable") + } +} diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go new file mode 100644 index 000000000..0509f489c --- /dev/null +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -0,0 +1,219 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type finishObservationFixture struct { + s *store.Store + writer *store.Store + pool *pgxpool.Pool + tenant string + session store.Session + dispatcher Dispatcher +} + +func newFinishObservationFixture(t *testing.T, maxConnections int32) finishObservationFixture { + t.Helper() + var cfg *pgxpool.Config + s, lease := resetManagerStoreConfig(t, func(c *pgxpool.Config) { + if maxConnections > 0 { + c.MaxConns = maxConnections + } + cfg = c.Copy() + }) + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) + provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://fixture.example/v1", APIKey: "fixture-only"} + if _, err = s.SetDeploymentModelProvider(admin, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); err != nil { + t.Fatal(err) + } + snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") + if err != nil { + t.Fatal(err) + } + tenant := uuid.NewString() + model, harness := "fixture-model", "codex" + input := store.CreateSessionInput{Creator: identity.Subject{Kind: "service_account", ID: "fixture"}, Engine: harness, IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"fixture-model"},"environment":{"type":"none"}}`), ModelProvider: snapshot.Provider, ModelProviderSource: "deployment", DeploymentProviderRevision: snapshot.Revision, + ExecutionConfiguration: &v1.SessionExecutionConfiguration{Model: v1.ExecutionSelection{Value: &model, Source: "session"}, Harness: v1.ExecutionSelection{Value: &harness, Source: "deployment"}, ModelProvider: v1.ExecutionProviderSelection{Source: "deployment"}}} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + return finishObservationFixture{s, lease.Store(), pool, tenant, session, Dispatcher{Store: lease.Store()}} +} +func (f finishObservationFixture) start(t *testing.T) store.InputReceipt { + t.Helper() + receipt, err := f.s.SubmitMessage(t.Context(), f.tenant, f.session.ID, uuid.NewString(), json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"fixture"}]}]}`)) + if err != nil { + t.Fatal(err) + } + if _, err = f.writer.TransitionTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + return receipt +} +func (f finishObservationFixture) fields(t *testing.T) (*time.Time, *string) { + t.Helper() + rows, err := f.s.ListDeploymentModelProviders(t.Context()) + if err != nil || len(rows) != 1 { + t.Fatal(err) + } + return rows[0].LastUsedAt, rows[0].LastErrorCode +} +func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { + cases := []struct { + name, status, core, native string + unapplied, invalid, used, failed bool + }{ + {name: "success", status: store.TurnCompleted, used: true}, + {name: "provider_failure", status: store.TurnFailed, core: "engine_failed", native: "authentication_error", failed: true}, + {name: "runtime_dominates", status: store.TurnFailed, core: "device_disconnected", native: "authentication_error"}, + {name: "input_policy", status: store.TurnFailed, core: "engine_failed", native: "cyber_policy"}, + {name: "cancelled", status: store.TurnCancelled, core: "engine_failed", native: "authentication_error"}, + {name: "fallback", status: store.TurnCompleted, core: "engine_failed", native: "authentication_error", unapplied: true}, + {name: "invalid_result", status: store.TurnCompleted, invalid: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFinishObservationFixture(t, 0) + receipt := f.start(t) + result := Result{ErrorCode: tc.core, EngineErrorCode: tc.native, AppliedThrough: receipt.Sequence} + if tc.unapplied { + result.AppliedThrough = 0 + } + if tc.invalid { + result.Error = strings.Repeat("x", 513*1024) + } + turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) + if err != nil { + t.Fatal(err) + } + if tc.unapplied || tc.invalid { + if turn.Status != store.TurnFailed { + t.Fatal("fallback not final", turn.Status) + } + } + used, code := f.fields(t) + if (used != nil) != tc.used || (code != nil) != tc.failed { + t.Fatalf("unexpected observation: used=%v code=%v", used, code) + } + if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_used_at=NULL,last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { + t.Fatal(err) + } + _, err = f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) + if !errors.Is(err, store.ErrTurnConflict) { + t.Fatal("expected completion conflict", err) + } + used, code = f.fields(t) + if used != nil || code != nil { + t.Fatal("failed completion observed") + } + }) + } +} +func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { + for _, mode := range []string{"lock_timeout", "query_failure", "pool_timeout"} { + t.Run(mode, func(t *testing.T) { + // One leased connection plus one ordinary connection gives an independently + // observable pool-acquisition deadline without starving the completion writer. + max := int32(0) + if mode == "pool_timeout" { + max = 1 + } + f := newFinishObservationFixture(t, 0) + receipt := f.start(t) + var cleanup func() + if mode == "lock_timeout" { + tx, err := f.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { + t.Fatal(err) + } + cleanup = func() { _ = tx.Rollback(context.Background()) } + } + if mode == "query_failure" { + _, err := f.pool.Exec(t.Context(), `CREATE FUNCTION reject_provider_observation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture secret must never be logged'; END $$; CREATE TRIGGER reject_provider_observation BEFORE UPDATE ON deployment_model_providers FOR EACH ROW EXECUTE FUNCTION reject_provider_observation()`) + if err != nil { + t.Fatal(err) + } + } + if mode == "pool_timeout" { + // A separate single-connection Store owns its leased connection. Only the + // best-effort metadata query needs this exhausted pool after completion. + cfg := f.pool.Config().Copy() + cfg.MaxConns = max + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + // Observe directly through a Dispatcher using the saturated pool; the final + // commit below remains on the real existing lease. + conn, err := pool.Acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + defer conn.Release() + turn, err := f.writer.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, store.TurnCompleted, json.RawMessage(`{}`), "", receipt.Sequence) + if err != nil { + t.Fatal(err) + } + d := Dispatcher{Store: store.New(pool)} + started := time.Now() + d.observeDeploymentProvider(f.tenant, f.session.ID, turn) + if elapsed := time.Since(started); elapsed < 900*time.Millisecond || elapsed > 2*time.Second { + t.Fatal("pool timeout exceeded observation budget", elapsed) + } + if err = f.writer.CheckExecutionOwnership(t.Context()); err != nil { + t.Fatal("observation cancelled lease", err) + } + return + } + if cleanup != nil { + defer cleanup() + } + started := time.Now() + turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", Result{AppliedThrough: receipt.Sequence}, store.TurnCompleted) + elapsed := time.Since(started) + if err != nil || turn.Status != store.TurnCompleted { + t.Fatal("observation changed commit result", err, turn.Status) + } + if elapsed > 2*time.Second || (mode == "lock_timeout" && elapsed < 900*time.Millisecond) { + t.Fatal("observation duration", elapsed) + } + if cleanup != nil { + cleanup() + } + persisted, err := f.s.GetTurn(t.Context(), f.tenant, f.session.ID, receipt.TurnID) + if err != nil || persisted.Status != store.TurnCompleted { + t.Fatal("terminal outcome lost", err) + } + used, code := f.fields(t) + if used != nil || code != nil { + t.Fatal("failed observation wrote") + } + if err = f.writer.CheckExecutionOwnership(t.Context()); err != nil { + t.Fatal("observation cancelled execution lease", err) + } + }) + } +} diff --git a/services/agents-api/internal/execution/device_authority.go b/services/core/internal/execution/device_authority.go similarity index 88% rename from services/agents-api/internal/execution/device_authority.go rename to services/core/internal/execution/device_authority.go index b5cc877af..3ce3df4a9 100644 --- a/services/agents-api/internal/execution/device_authority.go +++ b/services/core/internal/execution/device_authority.go @@ -3,8 +3,8 @@ package execution import ( "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // authorizedRuntimePeer fences a connected socket against current credential diff --git a/services/agents-api/internal/execution/directory_preparation.go b/services/core/internal/execution/directory_preparation.go similarity index 94% rename from services/agents-api/internal/execution/directory_preparation.go rename to services/core/internal/execution/directory_preparation.go index a5c64a2f9..2c5820dbd 100644 --- a/services/agents-api/internal/execution/directory_preparation.go +++ b/services/core/internal/execution/directory_preparation.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (d *Dispatcher) readPreparedDirectory(ctx context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, read proto.WorkspaceReadPayload) directoryReadResult { diff --git a/services/agents-api/internal/execution/directory_preparation_test.go b/services/core/internal/execution/directory_preparation_test.go similarity index 82% rename from services/agents-api/internal/execution/directory_preparation_test.go rename to services/core/internal/execution/directory_preparation_test.go index 3ce3fd2aa..7c81cbeca 100644 --- a/services/agents-api/internal/execution/directory_preparation_test.go +++ b/services/core/internal/execution/directory_preparation_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestDirectoryPreparationRejectsForeignBindingBeforeTransport(t *testing.T) { diff --git a/services/agents-api/internal/execution/disabled_tools_test.go b/services/core/internal/execution/disabled_tools_test.go similarity index 93% rename from services/agents-api/internal/execution/disabled_tools_test.go rename to services/core/internal/execution/disabled_tools_test.go index 742d31ad7..05c5adedf 100644 --- a/services/agents-api/internal/execution/disabled_tools_test.go +++ b/services/core/internal/execution/disabled_tools_test.go @@ -4,10 +4,10 @@ import ( "encoding/json" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestDisabledToolsUseCommonOperationQualification(t *testing.T) { diff --git a/services/agents-api/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go similarity index 95% rename from services/agents-api/internal/execution/dispatcher.go rename to services/core/internal/execution/dispatcher.go index 3cab39fc1..667105dea 100644 --- a/services/agents-api/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -8,10 +8,10 @@ import ( "strings" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Snapshot is resolved internally; Daemon is not a public environment wire type. diff --git a/services/agents-api/internal/execution/engine_failure_test.go b/services/core/internal/execution/engine_failure_test.go similarity index 96% rename from services/agents-api/internal/execution/engine_failure_test.go rename to services/core/internal/execution/engine_failure_test.go index d0241e433..792dde41b 100644 --- a/services/agents-api/internal/execution/engine_failure_test.go +++ b/services/core/internal/execution/engine_failure_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestEngineClassificationSurvivesDrainWithoutChangingSettlement(t *testing.T) { diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/core/internal/execution/engine_profile.go similarity index 92% rename from services/agents-api/internal/execution/engine_profile.go rename to services/core/internal/execution/engine_profile.go index b54c2903a..f7416f9aa 100644 --- a/services/agents-api/internal/execution/engine_profile.go +++ b/services/core/internal/execution/engine_profile.go @@ -4,10 +4,10 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func profileError(err error) error { diff --git a/services/agents-api/internal/execution/engine_profile_test.go b/services/core/internal/execution/engine_profile_test.go similarity index 93% rename from services/agents-api/internal/execution/engine_profile_test.go rename to services/core/internal/execution/engine_profile_test.go index 80bf58033..16ea78f22 100644 --- a/services/agents-api/internal/execution/engine_profile_test.go +++ b/services/core/internal/execution/engine_profile_test.go @@ -5,10 +5,10 @@ import ( "errors" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestAcceptedEnginePlacements(t *testing.T) { diff --git a/services/core/internal/execution/environment.go b/services/core/internal/execution/environment.go new file mode 100644 index 000000000..20254bd3c --- /dev/null +++ b/services/core/internal/execution/environment.go @@ -0,0 +1,25 @@ +package execution + +import ( + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func resolveExecutionEnvironment(snapshot Snapshot) (string, bool, error) { + if snapshot.Environment != nil { + if snapshot.Environment.Type != "none" || snapshot.Daemon != nil { + return "", false, store.ErrInvalidInput + } + return "", true, nil + } + if snapshot.Daemon == nil { + return "", false, store.ErrInvalidInput + } + workDir := snapshot.Daemon.WorkDir + if workDir != "" && !filepath.IsAbs(workDir) && !strings.HasPrefix(workDir, "~/") { + return "", false, store.ErrInvalidInput + } + return workDir, false, nil +} diff --git a/services/agents-api/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go similarity index 98% rename from services/agents-api/internal/execution/environment_admission.go rename to services/core/internal/execution/environment_admission.go index da0da4a1c..47ca15d78 100644 --- a/services/agents-api/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -7,7 +7,7 @@ import ( "slices" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) var ( diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/core/internal/execution/environment_capabilities_test.go similarity index 94% rename from services/agents-api/internal/execution/environment_capabilities_test.go rename to services/core/internal/execution/environment_capabilities_test.go index c9e48bf29..abe8a087b 100644 --- a/services/agents-api/internal/execution/environment_capabilities_test.go +++ b/services/core/internal/execution/environment_capabilities_test.go @@ -2,8 +2,8 @@ package execution import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "slices" "testing" ) diff --git a/services/agents-api/internal/execution/environment_capability_profiles_test.go b/services/core/internal/execution/environment_capability_profiles_test.go similarity index 100% rename from services/agents-api/internal/execution/environment_capability_profiles_test.go rename to services/core/internal/execution/environment_capability_profiles_test.go diff --git a/services/agents-api/internal/execution/environment_connections.go b/services/core/internal/execution/environment_connections.go similarity index 100% rename from services/agents-api/internal/execution/environment_connections.go rename to services/core/internal/execution/environment_connections.go diff --git a/services/agents-api/internal/execution/environment_directory.go b/services/core/internal/execution/environment_directory.go similarity index 96% rename from services/agents-api/internal/execution/environment_directory.go rename to services/core/internal/execution/environment_directory.go index ac4883d35..cbfb46264 100644 --- a/services/agents-api/internal/execution/environment_directory.go +++ b/services/core/internal/execution/environment_directory.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type directoryReadResult struct { diff --git a/services/agents-api/internal/execution/environment_file_write.go b/services/core/internal/execution/environment_file_write.go similarity index 96% rename from services/agents-api/internal/execution/environment_file_write.go rename to services/core/internal/execution/environment_file_write.go index ce64702d6..5976d09c0 100644 --- a/services/agents-api/internal/execution/environment_file_write.go +++ b/services/core/internal/execution/environment_file_write.go @@ -8,9 +8,9 @@ import ( "fmt" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/core/internal/execution/environment_placement.go similarity index 92% rename from services/agents-api/internal/execution/environment_placement.go rename to services/core/internal/execution/environment_placement.go index 97293f52d..2f49ef458 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/core/internal/execution/environment_placement.go @@ -4,12 +4,12 @@ import ( "bytes" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type environmentPlacement struct { diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/core/internal/execution/environment_placement_test.go similarity index 97% rename from services/agents-api/internal/execution/environment_placement_test.go rename to services/core/internal/execution/environment_placement_test.go index 7d78a4854..060d32b82 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/core/internal/execution/environment_placement_test.go @@ -6,8 +6,8 @@ import ( "slices" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { diff --git a/services/core/internal/execution/environment_test.go b/services/core/internal/execution/environment_test.go new file mode 100644 index 000000000..cf9c1052c --- /dev/null +++ b/services/core/internal/execution/environment_test.go @@ -0,0 +1,30 @@ +package execution + +import ( + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "testing" +) + +func TestExecutionEnvironmentDoesNotDefaultToLocal(t *testing.T) { + cases := []struct { + name string + snapshot Snapshot + dir string + none, invalid bool + }{ + {"public none", Snapshot{Environment: &v1.Environment{Type: "none"}}, "", true, false}, + {"legacy device", Snapshot{Daemon: &DaemonConfig{WorkDir: "/workspace"}}, "/workspace", false, false}, + {"missing", Snapshot{}, "", false, true}, + {"conflicting", Snapshot{Environment: &v1.Environment{Type: "none"}, Daemon: &DaemonConfig{}}, "", false, true}, + {"unsupported", Snapshot{Environment: &v1.Environment{Type: "self_hosted"}}, "", false, true}, + {"relative", Snapshot{Daemon: &DaemonConfig{WorkDir: "workspace"}}, "", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir, none, err := resolveExecutionEnvironment(c.snapshot) + if (err != nil) != c.invalid || dir != c.dir || none != c.none { + t.Fatal(dir, none, err) + } + }) + } +} diff --git a/services/agents-api/internal/execution/executor_observation.go b/services/core/internal/execution/executor_observation.go similarity index 91% rename from services/agents-api/internal/execution/executor_observation.go rename to services/core/internal/execution/executor_observation.go index 12eec8293..9d1fd8132 100644 --- a/services/agents-api/internal/execution/executor_observation.go +++ b/services/core/internal/execution/executor_observation.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // These durations use one Core process monotonic clock. They describe control diff --git a/services/agents-api/internal/execution/executor_preparation.go b/services/core/internal/execution/executor_preparation.go similarity index 90% rename from services/agents-api/internal/execution/executor_preparation.go rename to services/core/internal/execution/executor_preparation.go index 11078598b..050e44526 100644 --- a/services/agents-api/internal/execution/executor_preparation.go +++ b/services/core/internal/execution/executor_preparation.go @@ -5,9 +5,9 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // prepareTurnExecutor reserves one admission on the Runtime-owned Executor. diff --git a/services/agents-api/internal/execution/finish.go b/services/core/internal/execution/finish.go similarity index 93% rename from services/agents-api/internal/execution/finish.go rename to services/core/internal/execution/finish.go index 4d4915a11..ab14c9c72 100644 --- a/services/agents-api/internal/execution/finish.go +++ b/services/core/internal/execution/finish.go @@ -3,7 +3,7 @@ package execution import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func finishDelivery(ctx context.Context, journal *journal, result *Result, cancelReply <-chan cancellationResult, pendingInput bool, functions *functionExchange) string { diff --git a/services/agents-api/internal/execution/function_images_test.go b/services/core/internal/execution/function_images_test.go similarity index 89% rename from services/agents-api/internal/execution/function_images_test.go rename to services/core/internal/execution/function_images_test.go index 7d0956b96..3a055f3bb 100644 --- a/services/agents-api/internal/execution/function_images_test.go +++ b/services/core/internal/execution/function_images_test.go @@ -5,10 +5,10 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestFunctionImageAdmission(t *testing.T) { diff --git a/services/core/internal/execution/functions.go b/services/core/internal/execution/functions.go new file mode 100644 index 000000000..6118cf278 --- /dev/null +++ b/services/core/internal/execution/functions.go @@ -0,0 +1,196 @@ +package execution + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { + tools := make([]proto.FunctionTool, 0, len(raw)) + names := map[string]bool{} + if len(raw) > 64 { + return nil, errors.New("at most 64 function tools are supported") + } + for _, value := range raw { + var tool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + DeferLoading bool `json:"defer_loading"` + } + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&tool); err != nil { + return nil, err + } + var schema map[string]json.RawMessage + if tool.Type != "function" || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, errors.New("execution requires unique functions with object schemas") + } + names[tool.Name] = true + tools = append(tools, proto.FunctionTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters, DeferLoading: tool.DeferLoading}) + } + return tools, nil +} + +type functionReply struct { + ack proto.InteractionDecisionAckPayload + err error +} + +type functionExchange struct { + store *store.Store + tenant, session, turn string + kind string + tools []proto.FunctionTool + callID string + reply <-chan functionReply +} + +func (f *functionExchange) record(ctx context.Context, env proto.Envelope) error { + var call proto.FunctionCallPayload + if env.ID != f.turn || env.DecodePayload(&call) != nil { + return errors.New("invalid function callback") + } + declared := false + for _, tool := range f.tools { + declared = declared || tool.Name == call.Name + } + if !declared { + return errors.New("undeclared function callback") + } + err := f.store.RecordFunctionCall(ctx, f.tenant, f.session, f.turn, store.FunctionCall{ + CallID: items.Identity(f.turn, "tool:"+call.CallID), ExecutorCallID: call.CallID, Name: call.Name, Arguments: call.Arguments, + }) + return f.unlessCancelling(ctx, err) +} + +func (f *functionExchange) start(ctx context.Context, peer *gateway.Session) error { + if f.reply != nil || len(f.tools) == 0 { + return nil + } + calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + for _, call := range calls { + if len(call.Result) == 0 { + continue + } + result, err := functionResult(call) + if err != nil { + return err + } + if err := requireFunctionResultImages(peer, f.kind, result); err != nil { + return err + } + env, err := proto.NewEnvelope(proto.TypeFunctionResult, f.turn, result) + if err != nil { + return err + } + replies := make(chan functionReply, 1) + f.callID, f.reply = call.CallID, replies + go func() { + ack, err := peer.SendAndWaitInteractionAck(ctx, env, result.DeliveryID) + replies <- functionReply{ack: ack, err: err} + }() + return nil + } + return nil +} + +func (f *functionExchange) confirm(ctx context.Context, reply functionReply) error { + id := f.callID + f.callID, f.reply = "", nil + if reply.err != nil { + return reply.err + } + if !reply.ack.Applied { + return fmt.Errorf("function result not applied: %s", reply.ack.ErrorCode) + } + return f.unlessCancelling(ctx, f.store.ConfirmFunctionResult(ctx, f.tenant, f.session, f.turn, id)) +} + +func (f *functionExchange) unlessCancelling(ctx context.Context, err error) error { + if errors.Is(err, store.ErrTurnConflict) { + turn, lookupErr := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) + if lookupErr == nil && !turn.CancelRequestedAt.IsZero() { + return nil + } + } + return err +} + +func (f *functionExchange) complete(ctx context.Context) error { + if len(f.tools) == 0 { + return nil + } + calls, err := f.store.PendingFunctionCalls(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + if len(calls) != 0 { + return errors.New("function calls remain unapplied") + } + turn, err := f.store.GetTurn(ctx, f.tenant, f.session, f.turn) + if err != nil { + return err + } + if turn.Status == store.TurnWaiting { + return errors.New("function turn has not resumed") + } + return nil +} + +func functionResult(call store.FunctionCall) (proto.FunctionResultPayload, error) { + var value struct { + Success *bool `json:"success"` + Output json.RawMessage `json:"output"` + Error *string `json:"error"` + } + if err := json.Unmarshal(call.Result, &value); err != nil { + return proto.FunctionResultPayload{}, err + } + if value.Success == nil { + return proto.FunctionResultPayload{}, errors.New("function result requires success") + } + result := proto.FunctionResultPayload{DeliveryID: "function:" + call.CallID, CallID: call.ExecutorCallID, Success: *value.Success, Content: []proto.InputContent{}} + output := bytes.TrimSpace(value.Output) + if len(output) > 0 && !bytes.Equal(output, []byte("null")) { + if output[0] == '"' { + var text string + if err := json.Unmarshal(output, &text); err != nil { + return result, err + } + result.Content = append(result.Content, proto.InputContent{Type: "input_text", Text: &text}) + } else if err := json.Unmarshal(output, &result.Content); err != nil { + return result, err + } + } + if value.Error != nil { + result.Content = append(result.Content, proto.InputContent{Type: "input_text", Text: value.Error}) + } + return result, result.ValidateContent() +} + +// Check only this result, not ordinary function declarations or text delivery. +func requireFunctionResultImages(peer *gateway.Session, kind string, result proto.FunctionResultPayload) error { + if !(proto.MessageInput{{Content: result.Content}}).HasImages() { + return nil + } + info, found, known := peer.AgentKindStatus(kind) + if !found || !known || !info.Available || !info.Capabilities.FunctionResultImages { + return errors.New("Runtime does not support function result images") + } + return nil +} diff --git a/services/core/internal/execution/functions_test.go b/services/core/internal/execution/functions_test.go new file mode 100644 index 000000000..57086ac78 --- /dev/null +++ b/services/core/internal/execution/functions_test.go @@ -0,0 +1,53 @@ +package execution + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func TestFunctionDefinitionsRejectUnsupportedConfiguration(t *testing.T) { + valid := json.RawMessage(`{"type":"function","name":"lookup","description":"Find it","parameters":{"type":"object","properties":{}},"defer_loading":false}`) + tools, err := functionTools([]json.RawMessage{valid}) + if err != nil || len(tools) != 1 || tools[0].Name != "lookup" || tools[0].Description != "Find it" { + t.Fatal(tools, err) + } + for _, raw := range []string{`{"type":"mcp"}`, `{"type":"function","name":"lookup","parameters":null}`, `{"type":"function","name":"lookup","parameters":{},"unknown":true}`} { + if _, err := functionTools([]json.RawMessage{json.RawMessage(raw)}); err == nil { + t.Fatal("unsupported configuration admitted", raw) + } + } + if _, err := functionTools([]json.RawMessage{valid, valid}); err == nil { + t.Fatal("duplicate function names") + } +} + +func TestFunctionResultPreservesCompleteContent(t *testing.T) { + text := func(value string) proto.InputContent { + return proto.InputContent{Type: "input_text", Text: &value} + } + imageURL := "data:image/png;base64,test" + for _, test := range []struct { + raw string + success bool + content []proto.InputContent + }{ + {`{"success":true,"output":""}`, true, []proto.InputContent{text("")}}, + {`{"success":true,"output":null,"error":null}`, true, []proto.InputContent{}}, + {`{"success":false,"error":"failed"}`, false, []proto.InputContent{text("failed")}}, + {`{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,test"},{"type":"input_text","text":""}],"error":"failed"}`, false, []proto.InputContent{text("before"), {Type: "input_image", ImageURL: &imageURL}, text(""), text("failed")}}, + } { + result, err := functionResult(store.FunctionCall{CallID: "public", ExecutorCallID: "native", Result: json.RawMessage(test.raw)}) + if err != nil || result.CallID != "native" || result.DeliveryID != "function:public" || result.Success != test.success || !reflect.DeepEqual(result.Content, test.content) { + t.Fatal(result, err) + } + } + for _, raw := range []string{`{}`, `{"success":null}`, `{"success":true,"output":{}}`, `{"success":true,"output":[{"type":"input_text"}]}`, `{"success":true,"output":[{"type":"input_audio","audio_url":"a"}]}`} { + if _, err := functionResult(store.FunctionCall{Result: json.RawMessage(raw)}); err == nil { + t.Fatal("invalid stored result converted", raw) + } + } +} diff --git a/services/agents-api/internal/execution/journal.go b/services/core/internal/execution/journal.go similarity index 96% rename from services/agents-api/internal/execution/journal.go rename to services/core/internal/execution/journal.go index ae22830de..b94d37393 100644 --- a/services/agents-api/internal/execution/journal.go +++ b/services/core/internal/execution/journal.go @@ -5,8 +5,8 @@ import ( "encoding/json" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type journal struct { diff --git a/services/agents-api/internal/execution/journal_test.go b/services/core/internal/execution/journal_test.go similarity index 97% rename from services/agents-api/internal/execution/journal_test.go rename to services/core/internal/execution/journal_test.go index 78935a8bc..defd66b2a 100644 --- a/services/agents-api/internal/execution/journal_test.go +++ b/services/core/internal/execution/journal_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type recoveringWriter struct { diff --git a/services/agents-api/internal/execution/mcode_profile_test.go b/services/core/internal/execution/mcode_profile_test.go similarity index 100% rename from services/agents-api/internal/execution/mcode_profile_test.go rename to services/core/internal/execution/mcode_profile_test.go diff --git a/services/core/internal/execution/mcp.go b/services/core/internal/execution/mcp.go new file mode 100644 index 000000000..a92286e25 --- /dev/null +++ b/services/core/internal/execution/mcp.go @@ -0,0 +1,97 @@ +package execution + +import ( + "bytes" + "encoding/json" + "errors" + "net/url" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type executionToolSet struct { + Functions []proto.FunctionTool + MCP []proto.MCPHTTPServer + Search bool + DisableProgrammatic bool +} + +func executionTools(raw []json.RawMessage) (executionToolSet, error) { + functions := make([]json.RawMessage, 0, len(raw)) + var servers []proto.MCPHTTPServer + search := false + disableProgrammatic := false + controls := map[string]bool{} + names := map[string]bool{} + for _, value := range raw { + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(value, &kind) != nil { + return executionToolSet{}, errors.New("invalid execution tool") + } + if kind.Type == "programmatic_tool_calling" || kind.Type == "web_search" { + if controls[kind.Type] { + return executionToolSet{}, errors.New("execution requires distinct tool controls") + } + controls[kind.Type] = true + if kind.Type == "programmatic_tool_calling" { + var control struct { + Type string `json:"type"` + Enabled *bool `json:"enabled"` + } + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if decoder.Decode(&control) != nil || control.Enabled == nil || *control.Enabled { + return executionToolSet{}, errors.New("programmatic tool calling is not qualified for execution") + } + disableProgrammatic = true + } else { + var control struct { + Mode string `json:"mode"` + } + if json.Unmarshal(value, &control) != nil || control.Mode != "disabled" { + return executionToolSet{}, errors.New("only disabled web search is qualified for execution") + } + } + continue + } + if kind.Type == "tool_search" { + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if decoder.Decode(&kind) != nil || search { + return executionToolSet{}, errors.New("execution requires one type-only tool_search declaration") + } + search = true + continue + } + if kind.Type != "mcp" { + functions = append(functions, value) + continue + } + var tool v1.MCPTool + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || (tool.ConnectionOrigin != "service" && tool.ConnectionOrigin != "environment") || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { + return executionToolSet{}, errors.New("unsupported execution MCP configuration") + } + u, err := url.Parse(tool.Transport.ServerURL) + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { + return executionToolSet{}, errors.New("unsupported execution MCP URL") + } + if tool.AllowedTools != nil { + for _, name := range *tool.AllowedTools { + if name == "" { + return executionToolSet{}, errors.New("invalid execution MCP tool name") + } + } + } + names[tool.ServerLabel] = true + servers = append(servers, proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, + ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) + } + resolved, err := functionTools(functions) + return executionToolSet{Functions: resolved, MCP: servers, Search: search, DisableProgrammatic: disableProgrammatic}, err +} diff --git a/services/core/internal/execution/mcp_credentials.go b/services/core/internal/execution/mcp_credentials.go new file mode 100644 index 000000000..edf6280af --- /dev/null +++ b/services/core/internal/execution/mcp_credentials.go @@ -0,0 +1,75 @@ +package execution + +import ( + "encoding/json" + "errors" + "net/url" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +// Resolve only the frozen decision. Never search current Vault contents during +// dispatch: a later credential must not change an anonymous or selected server. +func selectedMCPCredentials(snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { + invalid := errors.New("invalid frozen MCP credential binding") + tools := map[string]v1.MCPTool{} + for _, raw := range snapshot.Agent.Tools { + var tool v1.MCPTool + if json.Unmarshal(raw, &tool) != nil { + return nil, invalid + } + if tool.Type == "mcp" { + tools[tool.ServerLabel] = tool + } + } + attached := map[uuid.UUID]bool{} + for _, raw := range snapshot.VaultIDs { + id, err := uuid.Parse(raw) + if err != nil { + return nil, invalid + } + attached[id] = true + } + seen := map[string]bool{} + selected := map[string]store.MCPCredentialBinding{} + for _, binding := range snapshot.MCPCredentials { + tool, exists := tools[binding.ServerLabel] + if !exists || seen[binding.ServerLabel] || tool.Transport.ServerURL != binding.ServerURL { + return nil, invalid + } + seen[binding.ServerLabel] = true + if binding.CredentialID == "" { + if binding.VaultID != "" || binding.AuthType != "" || tool.CredentialID != nil { + return nil, invalid + } + continue + } + vaultID, err := uuid.Parse(binding.VaultID) + if err != nil || !attached[vaultID] || (binding.AuthType != "static_bearer" && binding.AuthType != "mcp_oauth") { + return nil, invalid + } + id, err := uuid.Parse(binding.CredentialID) + if err != nil { + return nil, invalid + } + if tool.CredentialID != nil { + declared, err := uuid.Parse(*tool.CredentialID) + if err != nil || declared != id { + return nil, invalid + } + } + endpoint, err := url.Parse(binding.ServerURL) + if err != nil || endpoint.Scheme != "https" { + return nil, invalid + } + selected[binding.ServerLabel] = binding + } + for label, tool := range tools { + if !seen[label] && (tool.CredentialID != nil || len(snapshot.VaultIDs) > 0 || len(snapshot.MCPCredentials) > 0) { + return nil, invalid + } + } + return selected, nil +} diff --git a/services/core/internal/execution/mcp_credentials_test.go b/services/core/internal/execution/mcp_credentials_test.go new file mode 100644 index 000000000..a23d4f79e --- /dev/null +++ b/services/core/internal/execution/mcp_credentials_test.go @@ -0,0 +1,61 @@ +package execution + +import ( + "encoding/json" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestMCPFrozenCredentialAdmission(t *testing.T) { + vault, credential := uuid.NewString(), uuid.NewString() + for _, mode := range []string{"implicit", "explicit", "oauth implicit", "oauth explicit", "anonymous", "missing", "unattached", "wrong URL", "wrong auth", "changed selection", "HTTP", "remote", "self-hosted explicit", "self-hosted implicit", "self-hosted anonymous"} { + t.Run(mode, func(t *testing.T) { + tool := v1.MCPTool{Type: "mcp", ServerLabel: "tools", ConnectionOrigin: "service", Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example/tools"}} + binding := store.MCPCredentialBinding{ServerLabel: "tools", ServerURL: tool.Transport.ServerURL, VaultID: vault, CredentialID: credential, AuthType: "static_bearer"} + snapshot := Snapshot{Agent: v1.Agent{Model: "model"}, Environment: &v1.Environment{Type: "none"}, VaultIDs: []string{vault}} + switch mode { + case "explicit", "oauth explicit", "missing", "changed selection", "self-hosted explicit": + tool.CredentialID = &credential + case "anonymous", "self-hosted anonymous": + binding.VaultID, binding.CredentialID, binding.AuthType = "", "", "" + case "unattached": + snapshot.VaultIDs = nil + case "wrong URL": + binding.ServerURL += "/other" + case "wrong auth": + binding.AuthType = "other" + case "HTTP": + tool.Transport.ServerURL, binding.ServerURL = "http://mcp.example/tools", "http://mcp.example/tools" + case "remote": + snapshot.Daemon = &DaemonConfig{WorkDir: "/tmp"} + } + if strings.HasPrefix(mode, "oauth ") { + binding.AuthType = "mcp_oauth" + } + if strings.HasPrefix(mode, "self-hosted") { + snapshot.Environment = &v1.Environment{Type: "self_hosted", WorkspaceDirectory: "/workspace"} + } + if mode == "changed selection" { + binding.CredentialID = uuid.NewString() + } + snapshot.MCPCredentials = []store.MCPCredentialBinding{binding} + if mode == "missing" { + snapshot.MCPCredentials = nil + } + rawTool, _ := json.Marshal(tool) + snapshot.Agent.Tools = []json.RawMessage{rawTool} + raw, _ := json.Marshal(snapshot) + valid := mode == "implicit" || mode == "explicit" || mode == "anonymous" || strings.HasPrefix(mode, "oauth ") + for _, engine := range []string{"codex", "claude_sdk"} { + supported := valid && (engine == "codex" || !strings.HasPrefix(mode, "self-hosted")) + if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != supported { + t.Fatal("frozen binding profile decision differs", engine, err) + } + } + }) + } +} diff --git a/services/agents-api/internal/execution/mcp_support.go b/services/core/internal/execution/mcp_support.go similarity index 89% rename from services/agents-api/internal/execution/mcp_support.go rename to services/core/internal/execution/mcp_support.go index 40d7d5061..78bf1687f 100644 --- a/services/agents-api/internal/execution/mcp_support.go +++ b/services/core/internal/execution/mcp_support.go @@ -3,9 +3,9 @@ package execution import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (p Policy) mcpCredentialBindings(engine string, snapshot Snapshot) (map[string]store.MCPCredentialBinding, error) { diff --git a/services/agents-api/internal/execution/mcp_support_test.go b/services/core/internal/execution/mcp_support_test.go similarity index 95% rename from services/agents-api/internal/execution/mcp_support_test.go rename to services/core/internal/execution/mcp_support_test.go index cdb05d639..3c84609cc 100644 --- a/services/agents-api/internal/execution/mcp_support_test.go +++ b/services/core/internal/execution/mcp_support_test.go @@ -4,10 +4,10 @@ import ( "encoding/json" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/execution/mcp_test.go b/services/core/internal/execution/mcp_test.go similarity index 100% rename from services/agents-api/internal/execution/mcp_test.go rename to services/core/internal/execution/mcp_test.go diff --git a/services/core/internal/execution/message_input.go b/services/core/internal/execution/message_input.go new file mode 100644 index 000000000..5a296501b --- /dev/null +++ b/services/core/internal/execution/message_input.go @@ -0,0 +1,66 @@ +package execution + +import ( + "context" + "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func messageInput(raw json.RawMessage) (proto.MessageInput, error) { + var input struct { + Text *string `json:"text"` + Input []v1.InputMessage `json:"input"` + } + if json.Unmarshal(raw, &input) != nil { + return nil, store.ErrInvalidInput + } + var messages proto.MessageInput + if len(input.Input) == 0 && input.Text != nil { + messages = proto.TextInput(*input.Text) + } + for _, message := range input.Input { + if message.Role != "user" { + return nil, store.ErrInvalidInput + } + converted := proto.InputMessage{} + for _, part := range message.Content { + converted.Content = append(converted.Content, proto.InputContent{Type: part.Type, Text: part.Text, ImageURL: part.ImageURL}) + } + messages = append(messages, converted) + } + if messages.Validate() != nil { + return nil, store.ErrInvalidInput + } + return messages, nil +} + +func (d *Dispatcher) initialInput(ctx context.Context, tenant, session, turn string) (proto.MessageInput, int64, error) { + inputs, err := d.Store.ListTurnInputs(ctx, tenant, session, turn, 0, 100) + if err != nil { + return nil, 0, err + } + var messages proto.MessageInput + var through int64 + size := 0 + for _, input := range inputs { + if input.Kind != "message" { + return nil, 0, store.ErrInvalidInput + } + batch, err := messageInput(input.Payload) + if err != nil { + return nil, 0, err + } + if size+len(input.Payload) > 512*1024 && len(messages) > 0 { + break + } + messages = append(messages, batch...) + size += len(input.Payload) + through = input.Sequence + } + if len(messages) == 0 { + return nil, 0, store.ErrInvalidInput + } + return messages, through, nil +} diff --git a/services/agents-api/internal/execution/message_input_test.go b/services/core/internal/execution/message_input_test.go similarity index 100% rename from services/agents-api/internal/execution/message_input_test.go rename to services/core/internal/execution/message_input_test.go diff --git a/services/agents-api/internal/execution/message_support.go b/services/core/internal/execution/message_support.go similarity index 90% rename from services/agents-api/internal/execution/message_support.go rename to services/core/internal/execution/message_support.go index 7a855464b..2e685a10f 100644 --- a/services/agents-api/internal/execution/message_support.go +++ b/services/core/internal/execution/message_support.go @@ -4,10 +4,10 @@ import ( "errors" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // ErrWhitespaceOnlyText is a declared native limitation reported before any diff --git a/services/agents-api/internal/execution/message_support_test.go b/services/core/internal/execution/message_support_test.go similarity index 95% rename from services/agents-api/internal/execution/message_support_test.go rename to services/core/internal/execution/message_support_test.go index 72e01609c..c83712f09 100644 --- a/services/agents-api/internal/execution/message_support_test.go +++ b/services/core/internal/execution/message_support_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestMessageImageQualificationIsOperationSpecific(t *testing.T) { diff --git a/services/agents-api/internal/execution/model_execution.go b/services/core/internal/execution/model_execution.go similarity index 86% rename from services/agents-api/internal/execution/model_execution.go rename to services/core/internal/execution/model_execution.go index 462b8a1f1..fea9abaae 100644 --- a/services/agents-api/internal/execution/model_execution.go +++ b/services/core/internal/execution/model_execution.go @@ -4,8 +4,8 @@ import ( "context" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (d *Dispatcher) sessionModelOptions(ctx context.Context, session store.Session) (map[string]any, error) { diff --git a/services/agents-api/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go similarity index 94% rename from services/agents-api/internal/execution/model_execution_test.go rename to services/core/internal/execution/model_execution_test.go index e874f9f10..c593f417c 100644 --- a/services/agents-api/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSessionModelExecutionNeverFallsBack(t *testing.T) { diff --git a/services/agents-api/internal/execution/notifications.go b/services/core/internal/execution/notifications.go similarity index 100% rename from services/agents-api/internal/execution/notifications.go rename to services/core/internal/execution/notifications.go diff --git a/services/agents-api/internal/execution/notifications_test.go b/services/core/internal/execution/notifications_test.go similarity index 100% rename from services/agents-api/internal/execution/notifications_test.go rename to services/core/internal/execution/notifications_test.go diff --git a/services/agents-api/internal/execution/policy.go b/services/core/internal/execution/policy.go similarity index 79% rename from services/agents-api/internal/execution/policy.go rename to services/core/internal/execution/policy.go index c6feb4664..ec3a021b5 100644 --- a/services/agents-api/internal/execution/policy.go +++ b/services/core/internal/execution/policy.go @@ -1,6 +1,6 @@ package execution -import "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" +import "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" // Policy supplies immutable service qualification to admission and dispatch. // The zero value uses built-in profiles. Custom composition must supply the same diff --git a/services/core/internal/execution/preparation.go b/services/core/internal/execution/preparation.go new file mode 100644 index 000000000..0e0d2547d --- /dev/null +++ b/services/core/internal/execution/preparation.go @@ -0,0 +1,147 @@ +package execution + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +var errPreparationFailed = errors.New("runtime preparation failed before admission") + +type preparationRejection struct { + code string + operation string +} + +func (e *preparationRejection) Error() string { return "preparation control rejected: " + e.code } + +type preparedStart struct { + createdAt time.Time + startSentAt time.Time + startObserved bool + readyObserved bool + peer *gateway.Session + requestID string + handle string + executorID string + sub *gateway.Subscription +} + +func newPreparedStart(peer *gateway.Session) (*preparedStart, error) { + id := uuid.NewString() + sub, err := peer.SubscribePreparation(id) + if err != nil { + return nil, err + } + return &preparedStart{peer: peer, requestID: id, sub: sub, createdAt: time.Now()}, nil +} + +func (p *preparedStart) close() { + if p.handle != "" { + _ = send(context.Background(), p.peer, proto.TypeExecutionRelease, p.requestID, proto.ExecutionReleasePayload{Handle: p.handle}) + } + p.peer.UnsubscribePreparation(p.requestID) +} + +func (p *preparedStart) controlStatus(env proto.Envelope) (proto.PreparationStatusPayload, error) { + var status proto.PreparationStatusPayload + if env.Type != proto.TypePreparationStatus || env.ID != p.requestID || env.DecodePayload(&status) != nil { + return status, errors.New("invalid preparation control response") + } + if status.State == "rejected" { + return status, nil + } + if status.Handle == "" || status.Revision == 0 || (p.handle != "" && p.handle != status.Handle) { + return status, errors.New("preparation identity changed") + } + if p.executorID != "" && status.ExecutorID != p.executorID { + return status, errors.New("executor identity changed") + } + p.handle, p.executorID = status.Handle, status.ExecutorID + if status.State == "ready" && status.ExecutorID != "" && !p.readyObserved { + p.readyObserved = true + recordExecutorReadiness(p, status) + } + return status, nil +} + +func (p *preparedStart) observation(env proto.Envelope) (proto.PreparationStatusPayload, error) { + status, err := p.controlStatus(env) + if err != nil { + return status, err + } + if status.State == "rejected" { + return status, &preparationRejection{code: status.ErrorCode, operation: status.Operation} + } + if status.State == "failed" && status.ErrorCode == "preparation_failed" && status.RunID == "" { + return status, errPreparationFailed + } + switch status.State { + case "preparing", "ready", "starting", "started": + return status, nil + default: + return status, errors.New("preparation is no longer available") + } +} + +func (d *Dispatcher) awaitPreparation(ctx context.Context, tenant, session string, pending store.EnvironmentInputReservation, prepared *preparedStart) (store.EnvironmentInputReservation, error) { + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return pending, ctx.Err() + case <-tick.C: + current, err := d.Store.ExpireEnvironmentInput(ctx, tenant, session, pending.ID) + if err != nil || current.State != store.EnvironmentInputPending { + return current, err + } + case env, ok := <-prepared.sub.Events: + if !ok { + return pending, errors.New("preparation control stream closed") + } + status, err := prepared.observation(env) + if err != nil { + var rejection *preparationRejection + if status.RunID == "" && errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare { + switch rejection.code { + case "invalid_configuration", "unsupported_configuration", "unsupported_preparation": + return pending, errPreparationFailed + } + } + return pending, err + } + if status.State == "ready" && status.RunID == "" && status.ExecutorID != "" { + return pending, nil + } + if status.State != "preparing" { + return pending, errors.New("unexpected preparation state before admission") + } + } + } +} + +func (p *preparedStart) start(ctx context.Context, request proto.PromptRequestPayload) error { + p.startSentAt = time.Now() + return send(ctx, p.peer, proto.TypeExecutionStart, p.requestID, proto.ExecutionStartPayload{Handle: p.handle, ExecutorID: p.executorID, RunID: request.RunID, Input: request.Input}) +} + +func (p *preparedStart) started(env proto.Envelope, runID string) (bool, error) { + status, err := p.observation(env) + if err != nil { + return false, err + } + if status.RunID != runID || (status.State != "starting" && status.State != "started") { + return false, errors.New("unexpected preparation state after admission") + } + if status.State == "started" && !p.startObserved { + p.startObserved = true + recordExecutorStart(p, runID) + } + return status.State == "started", nil +} diff --git a/services/agents-api/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go similarity index 95% rename from services/agents-api/internal/execution/prepared_dispatch.go rename to services/core/internal/execution/prepared_dispatch.go index c03aa2bb0..5bb62f01f 100644 --- a/services/agents-api/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -6,9 +6,9 @@ import ( "errors" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type EnvironmentRun struct { diff --git a/services/core/internal/execution/provider_operations_fixture_test.go b/services/core/internal/execution/provider_operations_fixture_test.go new file mode 100644 index 000000000..17050ff85 --- /dev/null +++ b/services/core/internal/execution/provider_operations_fixture_test.go @@ -0,0 +1,70 @@ +package execution + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func (*lifecycleOnlySandbox) ProviderOperations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + } +} +func (*lifecycleOnlySandbox) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { + return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { + return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleOnlySandbox) VerifyCredential(context.Context, []sandbox.Reference) error { + return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} +} diff --git a/services/core/internal/execution/recovery_test.go b/services/core/internal/execution/recovery_test.go new file mode 100644 index 000000000..7b9b500e0 --- /dev/null +++ b/services/core/internal/execution/recovery_test.go @@ -0,0 +1,30 @@ +package execution + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func TestExistingSessionRecoveryRequiresVerifiedCapability(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk", "future-engine"} { + for _, started := range []bool{false, true} { + for _, nativeID := range []string{"", "native"} { + for _, capable := range []bool{false, true} { + wantRecovery := started && nativeID == "" + req, err := (&Dispatcher{}).executionRequest(t.Context(), store.Session{ID: "session", Engine: engine}, Snapshot{}, device.KindCapabilities{NativeSessionRecovery: capable}, store.SessionExecutionBinding{HasStartedTurn: started, NativeSessionID: nativeID}) + if wantRecovery && !capable { + if err == nil { + t.Fatal("unverified recovery admitted", engine) + } + continue + } + if err != nil || req.RequireExistingNativeSession != wantRecovery || req.AgentSessionID != nativeID || !req.StrictResume || req.AgentStateKey != "agents-api-session" { + t.Fatalf("engine=%s started=%v id=%s capability=%v request=%+v err=%v", engine, started, nativeID, capable, req, err) + } + } + } + } + } +} diff --git a/services/agents-api/internal/execution/request.go b/services/core/internal/execution/request.go similarity index 92% rename from services/agents-api/internal/execution/request.go rename to services/core/internal/execution/request.go index 167ae5e81..0ce8b2bb8 100644 --- a/services/agents-api/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -6,10 +6,10 @@ import ( "errors" "maps" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session, snapshot Snapshot, caps device.KindCapabilities, bound store.SessionExecutionBinding) (proto.PromptRequestPayload, error) { diff --git a/services/agents-api/internal/execution/runtime_cancellation.go b/services/core/internal/execution/runtime_cancellation.go similarity index 100% rename from services/agents-api/internal/execution/runtime_cancellation.go rename to services/core/internal/execution/runtime_cancellation.go diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/core/internal/execution/runtime_capabilities_test.go similarity index 93% rename from services/agents-api/internal/execution/runtime_capabilities_test.go rename to services/core/internal/execution/runtime_capabilities_test.go index c141327cc..5ba2f2c35 100644 --- a/services/agents-api/internal/execution/runtime_capabilities_test.go +++ b/services/core/internal/execution/runtime_capabilities_test.go @@ -4,10 +4,10 @@ import ( "bytes" "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "testing" ) diff --git a/services/agents-api/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go similarity index 97% rename from services/agents-api/internal/execution/runtime_compute.go rename to services/core/internal/execution/runtime_compute.go index eb4ed7804..055d0e2ba 100644 --- a/services/agents-api/internal/execution/runtime_compute.go +++ b/services/core/internal/execution/runtime_compute.go @@ -6,9 +6,9 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/execution/runtime_compute_wake.go b/services/core/internal/execution/runtime_compute_wake.go similarity index 94% rename from services/agents-api/internal/execution/runtime_compute_wake.go rename to services/core/internal/execution/runtime_compute_wake.go index 5a1ad04ab..7b01fefd8 100644 --- a/services/agents-api/internal/execution/runtime_compute_wake.go +++ b/services/core/internal/execution/runtime_compute_wake.go @@ -5,10 +5,10 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (r *runtimeLifecycle) wakeCompute(ctx context.Context, p sandbox.CheckpointProvider, owner store.RuntimeAllocation, state runtimeCompute) error { diff --git a/services/agents-api/internal/execution/runtime_connections.go b/services/core/internal/execution/runtime_connections.go similarity index 95% rename from services/agents-api/internal/execution/runtime_connections.go rename to services/core/internal/execution/runtime_connections.go index 359c9a54a..501b901c9 100644 --- a/services/agents-api/internal/execution/runtime_connections.go +++ b/services/core/internal/execution/runtime_connections.go @@ -6,8 +6,8 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Access is serialized by the existing lifecycle gate. Durable generations fence diff --git a/services/agents-api/internal/execution/runtime_connections_test.go b/services/core/internal/execution/runtime_connections_test.go similarity index 91% rename from services/agents-api/internal/execution/runtime_connections_test.go rename to services/core/internal/execution/runtime_connections_test.go index 40148de39..fcbffafcb 100644 --- a/services/agents-api/internal/execution/runtime_connections_test.go +++ b/services/core/internal/execution/runtime_connections_test.go @@ -3,7 +3,7 @@ package execution import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestRuntimeCleanupDropsOnlyOwnedEnvironmentConnection(t *testing.T) { diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go new file mode 100644 index 000000000..8ec8597db --- /dev/null +++ b/services/core/internal/execution/runtime_initialization.go @@ -0,0 +1,164 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// The leased Worker owns preparation for every Environment. Compute managers +// never run initialization. Bounded concurrent owners prevent one slow machine +// from blocking other environments or resource reclamation. +func (w *Worker) runEnvironmentInitializations(ctx context.Context) error { + active := make(map[string]bool) + done := make(chan string, w.executionConcurrency()) + var running sync.WaitGroup + ctx, stop := context.WithCancel(ctx) + defer func() { stop(); running.Wait() }() + cursor := "" + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case id := <-done: + delete(active, id) + case <-ticker.C: + } + rows, err := w.dispatcher.Store.ListEnvironmentInitializations(ctx, cursor) + if err != nil { + return err + } + if len(rows) == 0 { + cursor = "" + } + for _, owner := range rows { + cursor = owner.EnvironmentID + if active[owner.EnvironmentID] { + continue + } + if owner.State == "running" { + // Lost process-local progress cannot prove which side effects ran. + if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{}); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + continue + } + if len(active) >= w.executionConcurrency() { + continue + } + peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, gateway.ErrSessionClosed) || errors.Is(err, gateway.ErrDeviceNotRegistered) { + continue + } + return err + } + harness, found, known := peer.AgentKindStatus(owner.Engine) + if !known { + continue + } + if !found || !harness.Available { + if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{Step: store.ProvisioningHarness}); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + continue + } + if err := w.dispatcher.Store.ClaimEnvironmentInitialization(ctx, owner); err != nil { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrTurnConflict) { + continue + } + return err + } + active[owner.EnvironmentID] = true + running.Add(1) + go func(owner store.EnvironmentInitialization) { + defer running.Done() + w.initializeEnvironment(ctx, owner) + done <- owner.EnvironmentID + }(owner) + } + } +} + +func (w *Worker) initializeEnvironment(ctx context.Context, owner store.EnvironmentInitialization) { + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + failure := store.ProvisioningFailure{} + err := w.prepareEnvironment(operation, owner, &failure) + if err == nil { + err = w.dispatcher.Store.CompleteEnvironmentInitialization(operation, owner) + } + if err != nil { + log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID) + // A later scan settles an unrecorded failure; it never retries the setup. + record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer stop() + _ = w.dispatcher.Store.FailEnvironmentInitialization(record, owner, failure) + } +} + +func (w *Worker) prepareEnvironment(ctx context.Context, owner store.EnvironmentInitialization, failure *store.ProvisioningFailure) error { + environment, err := w.dispatcher.Store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) + if err != nil { + return err + } + var cfg struct { + Files []store.InitialFileMetadata `json:"files"` + } + if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { + return store.ErrInvalidInput + } + setup, err := w.dispatcher.Store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) + if err != nil { + return err + } + peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) + if err != nil { + return err + } + identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID} + operations := setupOperations(setup) + for index := 0; index < len(cfg.Files)+len(operations); index++ { + step, stop := context.WithTimeout(ctx, 2*time.Minute) + err = w.dispatcher.Store.CheckExecutionOwnership(step) + if err == nil { + var currentPeer = peer + currentPeer, err = w.dispatcher.authorizedPeer(step, owner.DeviceID) + if err == nil && currentPeer != peer { + err = errors.New("Runtime connection changed during initialization") + } + } + candidate := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} + if err == nil && index < len(cfg.Files) { + var metadata store.InitialFileMetadata + var body []byte + metadata, body, err = w.dispatcher.Store.ReadInitialEnvironmentFile(step, owner.TenantID, owner.SessionID, index) + if err == nil { + err = installInitialFile(step, peer, identity, metadata, body) + } + } else if err == nil { + command := operations[index-len(cfg.Files)] + candidate = command.provisioningFailure(0) + err = runRuntimeSetup(step, peer, identity, command) + } + stop() + if err != nil { + var confirmed *runtimeStepFailure + if errors.As(err, &confirmed) { + candidate.ExitCode = confirmed.exitCode + *failure = candidate + } + return err + } + } + return nil +} diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go similarity index 97% rename from services/agents-api/internal/execution/runtime_lifecycle.go rename to services/core/internal/execution/runtime_lifecycle.go index b084a7daa..0f0aefabf 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -11,11 +11,11 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // RuntimeProvider binds one deployment to one sandbox installation. diff --git a/services/agents-api/internal/execution/runtime_manager.go b/services/core/internal/execution/runtime_manager.go similarity index 98% rename from services/agents-api/internal/execution/runtime_manager.go rename to services/core/internal/execution/runtime_manager.go index 3678c408c..68fb2f4b3 100644 --- a/services/agents-api/internal/execution/runtime_manager.go +++ b/services/core/internal/execution/runtime_manager.go @@ -7,8 +7,8 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // runtimeManager owns node membership, not provider operations. Each registered diff --git a/services/agents-api/internal/execution/runtime_manager_test.go b/services/core/internal/execution/runtime_manager_test.go similarity index 100% rename from services/agents-api/internal/execution/runtime_manager_test.go rename to services/core/internal/execution/runtime_manager_test.go diff --git a/services/core/internal/execution/runtime_observation.go b/services/core/internal/execution/runtime_observation.go new file mode 100644 index 000000000..82b5ccfd9 --- /dev/null +++ b/services/core/internal/execution/runtime_observation.go @@ -0,0 +1,36 @@ +package execution + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func (r *runtimeLifecycle) recordObservation(ctx context.Context, owner store.RuntimeAllocation, observed error) { + if owner.NodeID == "" { + return + } + diagnostic := "" + if observed != nil { + switch { + case errors.Is(observed, sandbox.ErrOwnership): + diagnostic = "ownership_mismatch" + case errors.Is(observed, sandbox.ErrNotFound): + diagnostic = "compute_unconfirmed" + if owner.CreateSettled { + diagnostic = "resource_missing" + } + case errors.Is(observed, sandbox.ErrComputeUnconfirmed), errors.Is(observed, sandbox.ErrCommandUnconfirmed): + diagnostic = "compute_unconfirmed" + default: + diagnostic = "provider_unavailable" + if online, err := r.store.RuntimeNodeAvailable(ctx, owner.NodeID); err == nil && !online { + diagnostic = "node_unavailable" + } + } + } + // Reconciliation remains authoritative; diagnostics must not interrupt cleanup. + _ = r.store.RecordRuntimeObservation(ctx, owner, diagnostic) +} diff --git a/services/agents-api/internal/execution/runtime_pending.go b/services/core/internal/execution/runtime_pending.go similarity index 94% rename from services/agents-api/internal/execution/runtime_pending.go rename to services/core/internal/execution/runtime_pending.go index 2cac9afa3..4f6950e9c 100644 --- a/services/agents-api/internal/execution/runtime_pending.go +++ b/services/core/internal/execution/runtime_pending.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) // provisionPending shares the existing lifecycle owner and serial gate. This diff --git a/services/agents-api/internal/execution/runtime_protocol_test.go b/services/core/internal/execution/runtime_protocol_test.go similarity index 96% rename from services/agents-api/internal/execution/runtime_protocol_test.go rename to services/core/internal/execution/runtime_protocol_test.go index 02b65a99c..c64157687 100644 --- a/services/agents-api/internal/execution/runtime_protocol_test.go +++ b/services/core/internal/execution/runtime_protocol_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) // Public Turn failure can describe lost orchestration, independently of native diff --git a/services/agents-api/internal/execution/runtime_retirement_failure_test.go b/services/core/internal/execution/runtime_retirement_failure_test.go similarity index 98% rename from services/agents-api/internal/execution/runtime_retirement_failure_test.go rename to services/core/internal/execution/runtime_retirement_failure_test.go index 8be2b55b6..ec25f666c 100644 --- a/services/agents-api/internal/execution/runtime_retirement_failure_test.go +++ b/services/core/internal/execution/runtime_retirement_failure_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go similarity index 95% rename from services/agents-api/internal/execution/runtime_setup.go rename to services/core/internal/execution/runtime_setup.go index 4dc4cf6de..3b63c6a6b 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/core/internal/execution/runtime_setup.go @@ -4,9 +4,9 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/execution/runtime_setup_test.go b/services/core/internal/execution/runtime_setup_test.go similarity index 94% rename from services/agents-api/internal/execution/runtime_setup_test.go rename to services/core/internal/execution/runtime_setup_test.go index 5955e2fa9..38c75c7de 100644 --- a/services/agents-api/internal/execution/runtime_setup_test.go +++ b/services/core/internal/execution/runtime_setup_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const setupCanary = "CANARY-runtime-setup-9b3e" diff --git a/services/agents-api/internal/execution/runtime_wake_hint.go b/services/core/internal/execution/runtime_wake_hint.go similarity index 97% rename from services/agents-api/internal/execution/runtime_wake_hint.go rename to services/core/internal/execution/runtime_wake_hint.go index 82320309e..8c41d2c52 100644 --- a/services/agents-api/internal/execution/runtime_wake_hint.go +++ b/services/core/internal/execution/runtime_wake_hint.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // A hint only accelerates observation of an already committed input. Lookup or diff --git a/services/agents-api/internal/execution/runtime_wake_hint_test.go b/services/core/internal/execution/runtime_wake_hint_test.go similarity index 100% rename from services/agents-api/internal/execution/runtime_wake_hint_test.go rename to services/core/internal/execution/runtime_wake_hint_test.go diff --git a/services/agents-api/internal/execution/sandbox_deployment_drain_test.go b/services/core/internal/execution/sandbox_deployment_drain_test.go similarity index 97% rename from services/agents-api/internal/execution/sandbox_deployment_drain_test.go rename to services/core/internal/execution/sandbox_deployment_drain_test.go index 0aece86b4..93f01d3dd 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_drain_test.go +++ b/services/core/internal/execution/sandbox_deployment_drain_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/core/internal/execution/sandbox_deployment_setup.go b/services/core/internal/execution/sandbox_deployment_setup.go new file mode 100644 index 000000000..10c39fadc --- /dev/null +++ b/services/core/internal/execution/sandbox_deployment_setup.go @@ -0,0 +1,177 @@ +package execution + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// PreparedRuntimeDeployment has completed provider validation without publishing +// a selection. Publish must only update in-memory state and must not fail. +// Selection is the resolved typed configuration returned by preparation. +type PreparedRuntimeDeployment struct { + Config *RuntimeProvider + Publish func(*RuntimeProvider) + Selection *sandbox.Selection + VerifyCredential func(context.Context) error + FenceCredential func(context.Context) (func(), error) +} + +type RuntimeDeploymentPreparer func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) + +// NewDeferredRuntimeProvider enables Web setup for one fixed installation. The +// loader returns nil until selection, then the committed immutable generation. +// Replacement is serialized by the deployment mutation gate and drain flow. +func NewDeferredRuntimeProvider(installationID string, load func(context.Context) (*RuntimeProvider, error), prepare ...RuntimeDeploymentPreparer) *RuntimeProvider { + config := &RuntimeProvider{InstallationID: installationID, loadDeployment: load} + if len(prepare) == 1 { + config.prepareDeployment = prepare[0] + } + return config +} + +func (w *Worker) InitializeSandboxDeployment(ctx context.Context, input store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) { + unlock, err := w.runtimes.lockMutation(ctx) + if err != nil { + return store.RuntimeDeploymentView{}, err + } + defer unlock() + m := w.runtimes + if err := m.store.CheckSandboxDeploymentSetup(ctx, m.setupInstallationID, input); err != nil { + return store.RuntimeDeploymentView{}, err + } + candidate, err := m.prepareCandidate(ctx, input) + if err != nil { + return store.RuntimeDeploymentView{}, err + } + // An idempotent setup retry can arrive after an interrupted replacement. + // It must not reopen admission while that replacement is still draining. + m.mu.Lock() + switching := m.switching + m.mu.Unlock() + if switching { + if err := m.pauseDeployment(ctx); err != nil { + return store.RuntimeDeploymentView{}, err + } + } + result, err := m.store.InitializeSandboxDeployment(ctx, m.setupInstallationID, *candidate.Selection) + if err != nil { + return store.RuntimeDeploymentView{}, err + } + m.publishDeployment(candidate, result) + return result, nil +} + +// ensureDeployment serializes the first configuration read without holding the +// node map lock across database access. All node workers copy this same snapshot. +func (m *runtimeManager) ensureDeployment(parent context.Context) (bool, error) { + if m.loadDeployment == nil { + return true, nil + } + ctx, finish, err := m.enter(parent) + if err != nil { + return false, err + } + defer finish() + select { + case m.setupGate <- struct{}{}: + case <-ctx.Done(): + return false, ctx.Err() + } + defer func() { <-m.setupGate }() + m.mu.Lock() + ready := m.config.Provider != nil + m.mu.Unlock() + if ready { + return true, nil + } + config, err := m.loadDeployment(ctx) + // A missing local provider dependency blocks hosted execution, not the + // administrator's recovery API. The existing scan can load it after repair. + // Storage and ownership failures still stop the execution owner. + if errors.Is(err, ErrExecutionUnavailable) { + return false, nil + } + if err != nil || config == nil { + return false, err + } + if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || config.ProviderKind == "" { + return false, sandbox.ErrInvalid + } + copied, err := validatedRuntimeProvider(config, m.registry) + if err != nil { + return false, err + } + m.mu.Lock() + defer m.mu.Unlock() + if m.switching { + return false, errRuntimeTransition + } + if m.closed || ctx.Err() != nil { + return false, ErrExecutionUnavailable + } + m.config = copied + return true, nil +} + +// Preparation is outside the manager mutex and all database transactions. A +// rejected candidate cannot retire the current generation or its node lanes. +func (m *runtimeManager) prepareCandidate(ctx context.Context, input store.SandboxDeploymentSetupRequest) (PreparedRuntimeDeployment, error) { + if m.prepareDeployment == nil { + return PreparedRuntimeDeployment{}, ErrExecutionUnavailable + } + setup, err := store.SandboxSetupForSelection(m.setupInstallationID, input) + if err != nil { + return PreparedRuntimeDeployment{}, err + } + candidate, err := m.prepareDeployment(ctx, setup) + if err != nil { + return PreparedRuntimeDeployment{}, err + } + config := candidate.Config + if config == nil || config.InstallationID != setup.InstallationID || config.ProviderKind != setup.Provider || config.Mode != setup.Mode || config.CoreURL == "" || config.BackendFingerprint != setup.BackendFingerprint || config.LocalNodeID != "" || config.loadDeployment != nil || config.prepareDeployment != nil { + return PreparedRuntimeDeployment{}, sandbox.ErrInvalid + } + copied, err := validatedRuntimeProvider(config, m.registry) + if err != nil { + return PreparedRuntimeDeployment{}, err + } + if err := ctx.Err(); err != nil { + return PreparedRuntimeDeployment{}, err + } + m.mu.Lock() + closed := m.closed + m.mu.Unlock() + if closed { + return PreparedRuntimeDeployment{}, ErrExecutionUnavailable + } + if candidate.Selection == nil { + candidate.Selection = &input + } + if candidate.Selection.Provider != input.Provider { + return PreparedRuntimeDeployment{}, sandbox.ErrInvalid + } + candidate.Selection.ExpectedGeneration = input.ExpectedGeneration + candidate.Config = &copied + return candidate, nil +} + +// The store commit is the point of no return. Publishing a validated candidate +// is infallible, including when shutdown or request cancellation follows commit. +func (m *runtimeManager) publishDeployment(candidate PreparedRuntimeDeployment, committed store.RuntimeDeploymentView) { + m.mu.Lock() + defer m.mu.Unlock() + config := *candidate.Config + config.Generation, config.AdmissionPaused = committed.Generation, committed.Reset != nil + if m.switching { + m.nodes = make(map[string]*runtimeNode) + } + m.config = config + if candidate.Publish != nil { + candidate.Publish(&config) + } + m.switching = false + m.switchDrained = nil +} diff --git a/services/core/internal/execution/sandbox_deployment_setup_test.go b/services/core/internal/execution/sandbox_deployment_setup_test.go new file mode 100644 index 000000000..05cff4f73 --- /dev/null +++ b/services/core/internal/execution/sandbox_deployment_setup_test.go @@ -0,0 +1,212 @@ +package execution + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestDeferredSandboxDeploymentLoadsOnceBeforeNodeCreation(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + var selected atomic.Bool + var loads atomic.Int32 + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { + loads.Add(1) + if !selected.Load() { + return nil, nil + } + return configuration, nil + })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.stop(); m.drain() }) + if ready, err := m.ensureDeployment(t.Context()); err != nil || ready { + t.Fatal("empty setup became ready", ready, err) + } + if _, err := m.node("first"); !errors.Is(err, ErrExecutionUnavailable) { + t.Fatal("unconfigured node created", err) + } + selected.Store(true) + var wg sync.WaitGroup + for range 20 { + wg.Add(1) + go func() { + defer wg.Done() + if ready, err := m.ensureDeployment(t.Context()); err != nil || !ready { + t.Errorf("activation failed: %v %v", ready, err) + } + }() + } + wg.Wait() + if loads.Load() != 2 { + t.Fatal("configuration loaded again after selection", loads.Load()) + } + configuration.CoreURL = "https://changed.example/api/v1" + a, err := m.node("first") + if err != nil || a.lifecycle.config.CoreURL != "https://core.example/api/v1" { + t.Fatal("mutable config reached worker", err) + } + m.stop() + if _, err := m.ensureDeployment(t.Context()); !errors.Is(err, ErrExecutionUnavailable) { + t.Fatal("stopped manager activated", err) + } +} + +func TestDeferredSandboxDeploymentShutdownCancelsLoad(t *testing.T) { + entered := make(chan struct{}) + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(uuid.NewString(), func(ctx context.Context) (*RuntimeProvider, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + })) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := m.ensureDeployment(t.Context()); done <- err }() + <-entered + m.stop() + m.drain() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal("shutdown did not cancel setup load", err) + } +} + +func TestDeferredSandboxProviderFailureKeepsRecoveryAvailable(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + available := false + loadErr := ErrExecutionUnavailable + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { + if !available { + return nil, loadErr + } + return configuration, nil + })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.stop(); m.drain() }) + if nodes, err := m.syncNodes(t.Context()); err != nil || len(nodes) != 0 { + t.Fatal("unavailable provider stopped the manager", err) + } + if _, err := m.node("node"); !errors.Is(err, ErrExecutionUnavailable) { + t.Fatal("unavailable provider admitted execution", err) + } + loadErr = errors.New("storage failure") + if _, err := m.ensureDeployment(t.Context()); !errors.Is(err, loadErr) { + t.Fatal("provider recovery hid a storage failure", err) + } + available = true + if ready, err := m.ensureDeployment(t.Context()); err != nil || !ready { + t.Fatal("repaired provider did not activate", ready, err) + } +} + +func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + rejected := errors.New("candidate provider unavailable") + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil }, + func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) { + return PreparedRuntimeDeployment{}, rejected + })) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + if _, err := m.ensureDeployment(t.Context()); err != nil { + t.Fatal(err) + } + old, err := m.node(uuid.NewString()) + if err != nil { + t.Fatal(err) + } + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} + if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { + t.Fatal("candidate rejection was lost", err) + } + if m.config.Generation != 1 || m.config.Provider != config.Provider || m.switching || old.lifecycle.ctx.Err() != nil { + t.Fatal("rejected candidate replaced or drained the active configuration") + } + _, finish, err := m.enter(t.Context()) + if err != nil { + t.Fatal("candidate rejection stopped current execution", err) + } + finish() +} + +func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { + id := uuid.NewString() + entered, release := make(chan struct{}), make(chan struct{}) + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil }, + func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) { + close(entered) + <-release + return PreparedRuntimeDeployment{}, errors.New("rejected") + })) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = m.prepareCandidate(t.Context(), store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + }() + <-entered + stopped := make(chan struct{}) + go func() { m.stop(); close(stopped) }() + select { + case <-stopped: + case <-time.After(time.Second): + close(release) + <-done + t.Fatal("provider validation blocked manager shutdown") + } + close(release) + <-done +} + +func TestCommittedSandboxCandidatePublishesAfterShutdown(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })) + if err != nil { + t.Fatal(err) + } + if err := m.pauseDeployment(t.Context()); err != nil { + t.Fatal(err) + } + config := &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + var published *RuntimeProvider + candidate := PreparedRuntimeDeployment{Config: config, Publish: func(value *RuntimeProvider) { published = value }} + m.stop() + m.publishDeployment(candidate, store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b", Reset: &store.SandboxResetView{}}) + m.drain() + if m.config.Generation != 2 || !m.config.AdmissionPaused || published == nil || published.Generation != 2 || !published.AdmissionPaused || m.switching { + t.Fatal("committed candidate was lost during shutdown") + } + if _, _, err := m.enter(t.Context()); !errors.Is(err, ErrExecutionUnavailable) { + t.Fatal("publication reopened a closed manager", err) + } +} diff --git a/services/agents-api/internal/execution/sandbox_deployment_switch.go b/services/core/internal/execution/sandbox_deployment_switch.go similarity index 97% rename from services/agents-api/internal/execution/sandbox_deployment_switch.go rename to services/core/internal/execution/sandbox_deployment_switch.go index 53d6cca7c..05eb19111 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_switch.go +++ b/services/core/internal/execution/sandbox_deployment_switch.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (m *runtimeManager) lockMutation(ctx context.Context) (func(), error) { diff --git a/services/core/internal/execution/sandbox_deployment_switch_test.go b/services/core/internal/execution/sandbox_deployment_switch_test.go new file mode 100644 index 000000000..dcacef4de --- /dev/null +++ b/services/core/internal/execution/sandbox_deployment_switch_test.go @@ -0,0 +1,182 @@ +package execution + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestSandboxManagerSwitchDrainsBeforeDirectActivation(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + if _, err := m.ensureDeployment(t.Context()); err != nil { + t.Fatal(err) + } + old, err := m.node(uuid.NewString()) + if err != nil { + t.Fatal(err) + } + _, finish, err := m.enter(t.Context()) + if err != nil { + t.Fatal(err) + } + paused := make(chan error, 1) + go func() { paused <- m.pauseDeployment(t.Context()) }() + select { + case <-old.lifecycle.ctx.Done(): + case <-time.After(time.Second): + finish() + t.Fatal("old lifecycle not cancelled") + } + if _, err := m.node(uuid.NewString()); !errors.Is(err, errRuntimeTransition) { + finish() + t.Fatal("transition admitted new lifecycle", err) + } + select { + case err := <-paused: + finish() + t.Fatal("switch skipped active caller", err) + default: + } + finish() + select { + case err := <-paused: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("switch drain blocked") + } + config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + if err := m.activateDeployment(t.Context(), store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"}); err != nil { + t.Fatal(err) + } + if _, err := m.node(uuid.NewString()); !errors.Is(err, ErrExecutionUnavailable) { + t.Fatal("cloud created physical lifecycle", err) + } + current, err := m.node("") + if err != nil || current.lifecycle.config.Generation != 2 { + t.Fatal("cloud lifecycle not activated", err) + } + if len(m.nodes) != 1 { + t.Fatal("old lifecycle retained after activation") + } +} + +func TestSandboxManagerFailedActivationStaysPaused(t *testing.T) { + id := uuid.NewString() + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, errors.New("provider unavailable") })) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + if err := m.pauseDeployment(t.Context()); err != nil { + t.Fatal(err) + } + if err := m.activateDeployment(t.Context(), store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"}); err == nil { + t.Fatal("failed provider activated") + } + if _, _, err := m.enter(t.Context()); !errors.Is(err, errRuntimeTransition) { + t.Fatal("failed activation reopened work", err) + } +} + +func TestSandboxManagerCancelledSwitchCannotResumeBeforeDrain(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) + if err != nil { + t.Fatal(err) + } + if _, err := m.ensureDeployment(t.Context()); err != nil { + t.Fatal(err) + } + _, finish, err := m.enter(t.Context()) + if err != nil { + t.Fatal(err) + } + released := false + defer func() { + if !released { + finish() + } + m.stop() + m.drain() + }() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + if err := m.pauseDeployment(ctx); !errors.Is(err, context.DeadlineExceeded) { + cancel() + t.Fatal("initial pause did not wait for old caller", err) + } + cancel() + m.mu.Lock() + originalDrain := m.switchDrained + m.mu.Unlock() + config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} + expected := store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"} + ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) + if err := m.activateDeployment(ctx, expected); !errors.Is(err, context.DeadlineExceeded) { + cancel() + t.Fatal("resume bypassed outstanding drain", err) + } + cancel() + m.mu.Lock() + sameDrain := m.switchDrained == originalDrain + generation := m.config.Generation + m.mu.Unlock() + if !sameDrain || generation != 1 { + t.Fatal("retry replaced drain or activated configuration") + } + finish() + released = true + if err := m.activateDeployment(t.Context(), expected); err != nil { + t.Fatal("drained generation did not resume", err) + } +} + +func TestSandboxActivationCannotBypassOutstandingDrain(t *testing.T) { + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + id := uuid.NewString() + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, + func(context.Context) (*RuntimeProvider, error) { return nil, nil }, + func(_ context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { + return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}}, nil + })) + if err != nil { + t.Fatal(err) + } + _, finish, err := m.enter(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { finish(); m.stop(); m.drain() }() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + if err := m.pauseDeployment(ctx); !errors.Is(err, context.DeadlineExceeded) { + cancel() + t.Fatal("pause did not retain an outstanding caller", err) + } + cancel() + ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + err = m.activateDeployment(ctx, store.RuntimeDeploymentView{InstallationID: id, Generation: 1, Provider: "e2b", Mode: "direct"}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("activation bypassed the unfinished drain", err) + } +} diff --git a/services/core/internal/execution/sandbox_generations_test.go b/services/core/internal/execution/sandbox_generations_test.go new file mode 100644 index 000000000..cf861b9d9 --- /dev/null +++ b/services/core/internal/execution/sandbox_generations_test.go @@ -0,0 +1,121 @@ +package execution + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) { + s, lease := resetManagerStore(t) + writer := lease.Store() + id := uuid.NewString() + if err := writer.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + input := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "old-key", Template: "runtime:" + uuid.NewString()}} + input.Resources.CPUs = 2 + input.Resources.MemoryMiB = 2048 + if _, err := writer.InitializeSandboxDeployment(t.Context(), id, input); err != nil { + t.Fatal(err) + } + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + provider := hub.Proxy(uuid.NewString(), "docker", 1) + verifyCalls, published, fenced, released := 0, 0, 0, 0 + var rejectAt int + var rejection error = e2b.ErrTeamMismatch + config := NewDeferredRuntimeProvider(id, func(ctx context.Context) (*RuntimeProvider, error) { + setup, err := s.GetSandboxSetup(ctx) + if err != nil { + return nil, err + } + return &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}, nil + }, func(ctx context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { + return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}, + VerifyCredential: func(context.Context) error { + verifyCalls++ + if verifyCalls == rejectAt { + return rejection + } + return nil + }, + FenceCredential: func(context.Context) (func(), error) { fenced++; return func() { released++ }, nil }, Publish: func(*RuntimeProvider) { published++ }}, nil + }) + m, err := newRuntimeManager(writer, gateway.NewRegistry(), config) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + if _, err = m.ensureDeployment(t.Context()); err != nil { + t.Fatal(err) + } + old, err := m.node("") + if err != nil { + t.Fatal(err) + } + worker := &Worker{runtimes: m} + input.E2B.APIKey = "candidate-key" + request := store.SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1} + audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "test", RequestID: "test", TraceID: "test"}) + for _, failure := range []string{"preliminary", "final", "unanchored legacy or revoked committed key", "unknown ownership", "commit"} { + verifyCalls = 0 + rejectAt = 0 + ctx := audit + switch failure { + case "preliminary": + rejectAt = 1 + case "final": + rejectAt = 2 + case "unanchored legacy or revoked committed key": + rejectAt = 1 + rejection = &store.SandboxResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"} + case "unknown ownership": + rejectAt = 1 + rejection = e2b.ErrRequestUnconfirmed + case "commit": + ctx = t.Context() + } + if _, err := worker.UpdateSandboxDeployment(ctx, request); err == nil { + t.Fatal("failure published", failure) + } + committed, err := s.GetSandboxSetup(t.Context()) + if err != nil || committed.Generation != 1 || committed.E2B.APIKey != "old-key" || published != 0 || fenced != released { + t.Fatal("partial credential publication", failure, err) + } + current, err := m.node("") + if err != nil || current != old || m.switching { + t.Fatal("online failure drained an owned lifecycle", err) + } + } + verifyCalls = 0 + rejectAt = 0 + result, err := worker.UpdateSandboxDeployment(audit, request) + if err != nil || result.Generation != 2 || verifyCalls != 2 || published != 1 || fenced != released { + t.Fatal(result, verifyCalls, published, err) + } + current, err := m.node("") + if err != nil || current != old { + t.Fatal("online commit replaced lifecycle", err) + } + before := verifyCalls + if _, err := worker.UpdateSandboxDeployment(audit, request); err == nil { + t.Fatal("stale replay accepted") + } else { + var stale *store.SandboxGenerationStaleError + if !errors.As(err, &stale) { + t.Fatal(err) + } + } + if verifyCalls != before { + t.Fatal("stale request reached E2B") + } +} diff --git a/services/agents-api/internal/execution/sandbox_provider_contract_test.go b/services/core/internal/execution/sandbox_provider_contract_test.go similarity index 93% rename from services/agents-api/internal/execution/sandbox_provider_contract_test.go rename to services/core/internal/execution/sandbox_provider_contract_test.go index 1dfe0a915..34286419f 100644 --- a/services/agents-api/internal/execution/sandbox_provider_contract_test.go +++ b/services/core/internal/execution/sandbox_provider_contract_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/core/internal/execution/sandbox_reset.go b/services/core/internal/execution/sandbox_reset.go new file mode 100644 index 000000000..d8c3cd16f --- /dev/null +++ b/services/core/internal/execution/sandbox_reset.go @@ -0,0 +1,120 @@ +package execution + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func (w *Worker) StartSandboxReset(ctx context.Context, input store.SandboxResetRequest) (store.RuntimeDeploymentView, error) { + unlock, err := w.runtimes.lockMutation(ctx) + if err != nil { + return store.RuntimeDeploymentView{}, err + } + defer unlock() + return w.runtimes.store.StartSandboxReset(ctx, w.runtimes.setupInstallationID, input) +} + +func (w *Worker) CancelSandboxReset(ctx context.Context, generation uint64) (store.RuntimeDeploymentView, error) { + unlock, err := w.runtimes.lockMutation(ctx) + if err != nil { + return store.RuntimeDeploymentView{}, err + } + defer unlock() + return w.runtimes.store.CancelSandboxReset(ctx, w.runtimes.setupInstallationID, generation) +} + +// resetStep runs only on the manager's uncounted coordinator loop. It must never +// enter m.active, hold a Session/deployment transaction, or call a provider while +// waiting for a deployment drain. Each page has both a row and time bound. +func (m *runtimeManager) resetStep(parent context.Context) error { + if m.loadDeployment == nil { + return nil + } + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + return m.resetPage(parent, ctx) +} + +func (m *runtimeManager) resetPage(parent, ctx context.Context) error { + unlock, err := m.lockMutation(ctx) + if err != nil { + if ctx.Err() != nil && parent.Err() == nil { + return nil + } + return err + } + defer unlock() + if err := m.store.AdvanceSandboxResetDeadline(ctx); err != nil { + return err + } + current, err := m.store.GetRuntimeDeployment(ctx) + if err != nil { + return err + } + if current.Reset == nil { + m.resetCursor = "" + m.resetRequestedAt = time.Time{} + return nil + } + if !current.Reset.RequestedAt.Equal(m.resetRequestedAt) { + m.resetCursor = "" + m.resetRequestedAt = current.Reset.RequestedAt + } + candidates, err := m.store.ListSandboxResetSessions(ctx, m.resetCursor, current.Reset.Clear == "force") + if err != nil { + return err + } + for _, candidate := range candidates { + if ctx.Err() != nil { + return nil + } + _, err := m.store.ArchiveSandboxResetSession(ctx, candidate.TenantID, candidate.SessionID, current.Generation, current.Reset.RequestedAt) + m.resetCursor = candidate.SessionID + if err != nil && !errors.Is(err, store.ErrSandboxResetSessionBusy) && !errors.Is(err, store.ErrNotFound) { + // Do not log a provider body, request, credential or stored provenance. + log.Warn(ctx, "Sandbox reset archive remains pending", "session_id", candidate.SessionID) + } + } + if len(candidates) < 32 { + m.resetCursor = "" + } + if ctx.Err() != nil { + return nil + } + current, err = m.store.GetRuntimeDeployment(ctx) + if err != nil { + return err + } + if current.Reset == nil || current.Resources.Allocations != 0 || current.Resources.Pending != 0 { + return nil + } + if err := m.pauseDeployment(ctx); err != nil { + if recovery := m.restoreCommittedDeployment(); recovery != nil { + return errors.Join(err, recovery) + } + if parent.Err() == nil && ctx.Err() != nil && errors.Is(err, ctx.Err()) { + // A bounded page may expire during drain. Successful owner-context + // recovery keeps this durable reset available for the next tick. + return nil + } + return err + } + committed, err := m.store.CompleteSandboxReset(ctx, m.setupInstallationID, current.Generation, current.Reset.RequestedAt) + if err != nil { + recovery := m.restoreCommittedDeployment() + if recovery != nil { + return errors.Join(err, recovery) + } + // The transaction rechecks counts after the drain. A losing recheck + // leaves the durable clear intact for the next owner tick. + log.Warn(parent, "Sandbox reset completion remains pending", "generation", current.Generation) + return nil + } + m.publishEmptyDeployment(committed) + m.resetCursor = "" + return nil +} diff --git a/services/core/internal/execution/sandbox_reset_test.go b/services/core/internal/execution/sandbox_reset_test.go new file mode 100644 index 000000000..bf3f5840f --- /dev/null +++ b/services/core/internal/execution/sandbox_reset_test.go @@ -0,0 +1,179 @@ +package execution + +import ( + "bytes" + "context" + "database/sql" + "os" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +// The execution lease is database-scoped, so this manager test owns a database. +func resetManagerStore(t *testing.T) (*store.Store, *store.ExecutionLease) { + t.Helper() + return resetManagerStoreConfig(t, nil) +} + +func resetManagerStoreConfig(t *testing.T, configure func(*pgxpool.Config)) (*store.Store, *store.ExecutionLease) { + t.Helper() + url := os.Getenv("OAC_TEST_DATABASE_URL") + if url == "" { + t.Skip("OAC_TEST_DATABASE_URL is required") + } + admin, err := pgxpool.New(t.Context(), url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + name := "oac_reset_" + uuid.NewString()[:8] + "_tests" + quoted := pgx.Identifier{name}.Sanitize() + if _, err := admin.Exec(t.Context(), "CREATE DATABASE "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := admin.Exec(ctx, "DROP DATABASE "+quoted+" WITH (FORCE)"); err != nil { + t.Error(err) + } + }) + cfg := admin.Config().Copy() + cfg.ConnConfig.Database = name + db := sql.OpenDB(stdlib.GetConnector(*cfg.ConnConfig)) + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + _, err = provider.Up(t.Context()) + _ = db.Close() + if err != nil { + t.Fatal(err) + } + if configure != nil { + configure(cfg) + } + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + return s, lease +} + +func TestSandboxResetPageTimeoutRecoversCommittedOwner(t *testing.T) { + s, lease := resetManagerStore(t) + w := lease.Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + selection := store.SandboxDeploymentSetupRequest{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}} + selection.Resources.CPUs = 2 + selection.Resources.MemoryMiB = 2048 + if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { + t.Fatal(err) + } + hub := node.NewHub(node.HubOptions{}) + defer hub.Close() + loads := 0 + config := NewDeferredRuntimeProvider(id, func(ctx context.Context) (*RuntimeProvider, error) { + if ctx.Err() != nil { + t.Error("recovery inherited cancelled page") + } + loads++ + setup, err := s.GetSandboxSetup(ctx) + if err != nil || setup.Provider == "" { + return nil, err + } + return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Mode: setup.Mode, Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}, nil + }) + m, err := newRuntimeManager(w, gateway.NewRegistry(), config) + if err != nil { + t.Fatal(err) + } + defer func() { m.stop(); m.drain() }() + if _, err := m.ensureDeployment(t.Context()); err != nil { + t.Fatal(err) + } + old, err := m.node("") + if err != nil { + t.Fatal(err) + } + _, finish, err := m.enter(t.Context()) + if err != nil { + t.Fatal(err) + } + released := false + defer func() { + if !released { + finish() + } + }() + audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "reset-test", ActorLabel: "operator", RequestID: "request", TraceID: "trace"}) + reset, err := w.StartSandboxReset(audit, id, store.SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}) + if err != nil { + t.Fatal(err) + } + page, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- m.resetPage(t.Context(), page) }() + select { + case <-old.lifecycle.ctx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("reset never reached drain") + } + cancel() // Expire this page only after its drain barrier is established. + finish() + released = true + select { + case err := <-done: + if err != nil { + t.Fatal("recoverable page cancellation stopped owner", err) + } + case <-time.After(5 * time.Second): + t.Fatal("owner recovery blocked") + } + current, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || current.Reset == nil || current.Generation != 1 || !current.Reset.RequestedAt.Equal(reset.Reset.RequestedAt) { + t.Fatal("page timeout lost durable reset", current, err) + } + if loads < 2 { + t.Fatal("committed provider was not restored") + } + _, finishNext, err := m.enter(t.Context()) + if err != nil { + t.Fatal("recovery left barrier closed", err) + } + finishNext() + if err := m.resetStep(t.Context()); err != nil { + t.Fatal(err) + } + current, err = s.GetRuntimeDeployment(t.Context()) + if err != nil || current.Reset != nil || current.Provider != "" || current.Generation != 2 { + t.Fatal("next tick did not finish", current, err) + } +} diff --git a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go b/services/core/internal/execution/sandbox_snapshot_budget_test.go similarity index 91% rename from services/agents-api/internal/execution/sandbox_snapshot_budget_test.go rename to services/core/internal/execution/sandbox_snapshot_budget_test.go index 8608e536a..3f1fca867 100644 --- a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go +++ b/services/core/internal/execution/sandbox_snapshot_budget_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/node" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/node" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/agents-api/internal/execution/session_archive.go b/services/core/internal/execution/session_archive.go similarity index 85% rename from services/agents-api/internal/execution/session_archive.go rename to services/core/internal/execution/session_archive.go index e99eae482..269e5192d 100644 --- a/services/agents-api/internal/execution/session_archive.go +++ b/services/core/internal/execution/session_archive.go @@ -3,7 +3,7 @@ package execution import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // ArchiveManagedSession records administrative cleanup intent through the same diff --git a/services/agents-api/internal/execution/structured_output_test.go b/services/core/internal/execution/structured_output_test.go similarity index 87% rename from services/agents-api/internal/execution/structured_output_test.go rename to services/core/internal/execution/structured_output_test.go index b4b9fda20..5d32261fb 100644 --- a/services/agents-api/internal/execution/structured_output_test.go +++ b/services/core/internal/execution/structured_output_test.go @@ -5,10 +5,10 @@ import ( "encoding/json" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestStructuredOutputNeedsOperationQualification(t *testing.T) { diff --git a/services/agents-api/internal/execution/support.go b/services/core/internal/execution/support.go similarity index 95% rename from services/agents-api/internal/execution/support.go rename to services/core/internal/execution/support.go index 26f9e4126..4a9192253 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -5,10 +5,10 @@ import ( "errors" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // ValidateSessionConfiguration checks engine placement and configuration before persistence. diff --git a/services/agents-api/internal/execution/tool_search_test.go b/services/core/internal/execution/tool_search_test.go similarity index 95% rename from services/agents-api/internal/execution/tool_search_test.go rename to services/core/internal/execution/tool_search_test.go index eed3015f1..ec4526f44 100644 --- a/services/agents-api/internal/execution/tool_search_test.go +++ b/services/core/internal/execution/tool_search_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" ) const discoveryConfiguration = `{"agent":{"model":"model","tools":[{"type":"tool_search"},{"type":"function","name":"lookup","description":"Lookup","parameters":{"type":"object"},"defer_loading":true},{"type":"function","name":"clock","description":"Clock","parameters":{"type":"object"}}]},"environment":{"type":"none"}}` diff --git a/services/agents-api/internal/execution/worker.go b/services/core/internal/execution/worker.go similarity index 98% rename from services/agents-api/internal/execution/worker.go rename to services/core/internal/execution/worker.go index 7062a4c8e..03e42f4e1 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -7,9 +7,9 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const DefaultExecutionConcurrency = 4 diff --git a/services/agents-api/internal/execution/worker_concurrency_test.go b/services/core/internal/execution/worker_concurrency_test.go similarity index 100% rename from services/agents-api/internal/execution/worker_concurrency_test.go rename to services/core/internal/execution/worker_concurrency_test.go diff --git a/services/agents-api/internal/execution/worker_device.go b/services/core/internal/execution/worker_device.go similarity index 97% rename from services/agents-api/internal/execution/worker_device.go rename to services/core/internal/execution/worker_device.go index 3bd43d538..b02550227 100644 --- a/services/agents-api/internal/execution/worker_device.go +++ b/services/core/internal/execution/worker_device.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func (w *Worker) bind(ctx context.Context, item store.ExecutionWork) (bool, error) { diff --git a/services/agents-api/internal/execution/worker_metrics.go b/services/core/internal/execution/worker_metrics.go similarity index 100% rename from services/agents-api/internal/execution/worker_metrics.go rename to services/core/internal/execution/worker_metrics.go diff --git a/services/agents-api/internal/execution/worker_metrics_test.go b/services/core/internal/execution/worker_metrics_test.go similarity index 98% rename from services/agents-api/internal/execution/worker_metrics_test.go rename to services/core/internal/execution/worker_metrics_test.go index e877ac0d4..e3405c764 100644 --- a/services/agents-api/internal/execution/worker_metrics_test.go +++ b/services/core/internal/execution/worker_metrics_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerMetricsUnknownAndDetached(t *testing.T) { diff --git a/services/agents-api/internal/execution/worker_schedule.go b/services/core/internal/execution/worker_schedule.go similarity index 96% rename from services/agents-api/internal/execution/worker_schedule.go rename to services/core/internal/execution/worker_schedule.go index 9528de080..19b195095 100644 --- a/services/agents-api/internal/execution/worker_schedule.go +++ b/services/core/internal/execution/worker_schedule.go @@ -5,8 +5,8 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type workerSchedule struct { diff --git a/services/agents-api/internal/execution/worker_wakeup.go b/services/core/internal/execution/worker_wakeup.go similarity index 89% rename from services/agents-api/internal/execution/worker_wakeup.go rename to services/core/internal/execution/worker_wakeup.go index fa5539189..3c90a1f88 100644 --- a/services/agents-api/internal/execution/worker_wakeup.go +++ b/services/core/internal/execution/worker_wakeup.go @@ -3,7 +3,7 @@ package execution import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // wakeScheduler only hints at committed work. The existing loop retains lease, diff --git a/services/agents-api/internal/execution/worker_wakeup_test.go b/services/core/internal/execution/worker_wakeup_test.go similarity index 100% rename from services/agents-api/internal/execution/worker_wakeup_test.go rename to services/core/internal/execution/worker_wakeup_test.go diff --git a/services/agents-api/internal/execution/workspace_images_test.go b/services/core/internal/execution/workspace_images_test.go similarity index 91% rename from services/agents-api/internal/execution/workspace_images_test.go rename to services/core/internal/execution/workspace_images_test.go index 9ea797f9a..85e71c473 100644 --- a/services/agents-api/internal/execution/workspace_images_test.go +++ b/services/core/internal/execution/workspace_images_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestImageQualificationIsIndependentOfEnvironmentSource(t *testing.T) { diff --git a/services/agents-api/internal/identity/principal.go b/services/core/internal/identity/principal.go similarity index 100% rename from services/agents-api/internal/identity/principal.go rename to services/core/internal/identity/principal.go diff --git a/services/agents-api/internal/identity/subject.go b/services/core/internal/identity/subject.go similarity index 100% rename from services/agents-api/internal/identity/subject.go rename to services/core/internal/identity/subject.go diff --git a/services/agents-api/internal/items/command_output.go b/services/core/internal/items/command_output.go similarity index 88% rename from services/agents-api/internal/items/command_output.go rename to services/core/internal/items/command_output.go index 2be56835f..d10c7df9b 100644 --- a/services/agents-api/internal/items/command_output.go +++ b/services/core/internal/items/command_output.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func projectCommandOutput(turn string, raw json.RawMessage) ([]Update, error) { diff --git a/services/core/internal/items/command_output_test.go b/services/core/internal/items/command_output_test.go new file mode 100644 index 000000000..0eb5a50db --- /dev/null +++ b/services/core/internal/items/command_output_test.go @@ -0,0 +1,70 @@ +package items + +import ( + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func TestCommandOutputKeepsIdentityAndReplacesDraftAtCompletion(t *testing.T) { + previous := v1.Item{ID: Identity(testTurn, "tool:cmd"), TurnID: testTurn, Type: "command_execution", Command: "run", Status: "in_progress"} + for _, delta := range []string{"same\n", "same\n", "结束\n"} { + raw, _ := json.Marshal(map[string]string{"id": "cmd", "delta": delta}) + updates, err := Project(testTurn, "command_output", 1, raw) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(previous) + merged := mustMerge(t, updates[0], previous) + after, _ := json.Marshal(previous) + if string(before) != string(after) || *updates[0].CommandOutputDelta != delta || merged.ID != previous.ID || merged.Command != "run" { + t.Fatal("delta merge changed its input or command identity") + } + previous = merged + } + if string(encoded(previous.Output)) != `"same\nsame\n结束\n"` { + t.Fatal(string(encoded(previous.Output))) + } + for _, snapshot := range []struct{ raw, want string }{ + {``, `"same\nsame\n结束\n"`}, {`""`, `""`}, {`"authoritative"`, `"authoritative"`}, + } { + final := previous + final.Status, final.Output = "completed", nil + if snapshot.raw != "" { + final.Output = json.RawMessage(snapshot.raw) + } + merged := mustMerge(t, Update{Item: final}, previous) + if string(encoded(merged.Output)) != snapshot.want { + t.Fatal(string(encoded(merged.Output))) + } + late := "late" + got := mustMerge(t, Update{Item: final, CommandOutputDelta: &late}, merged) + if !reflect.DeepEqual(got, merged) { + t.Fatal("late output changed completed command") + } + } +} + +func TestCommandOutputRequiresMatchingCommand(t *testing.T) { + updates, err := Project(testTurn, "command_output", 1, []byte(`{"id":"cmd","delta":"text"}`)) + if err != nil { + t.Fatal(err) + } + for _, previous := range []v1.Item{ + {}, + {ID: updates[0].Item.ID, TurnID: testTurn, Type: "mcp_call"}, + {ID: updates[0].Item.ID, TurnID: "other", Type: "command_execution"}, + {ID: updates[0].Item.ID, TurnID: testTurn, Type: "command_execution", Status: "in_progress", Output: json.RawMessage(`{}`)}, + } { + if _, err := Merge(updates[0], previous); err == nil { + t.Fatal("invalid prior command accepted") + } + } + for _, raw := range []string{`{}`, `{"id":"cmd","delta":null}`, `{"id":"cmd","delta":""}`, `{"id":"cmd","delta":7}`, `{"delta":"text"}`} { + if _, err := Project(testTurn, "command_output", 1, []byte(raw)); err == nil { + t.Fatal("invalid delta accepted", raw) + } + } +} diff --git a/services/core/internal/items/inputs.go b/services/core/internal/items/inputs.go new file mode 100644 index 000000000..62d0efe8d --- /dev/null +++ b/services/core/internal/items/inputs.go @@ -0,0 +1,44 @@ +package items + +import ( + "encoding/json" + "strconv" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +func inputMessages(turn string, sequence int64, raw json.RawMessage) []Update { + var p struct { + Text *string `json:"text"` + Input []struct { + Role string `json:"role"` + Content []v1.ItemContent `json:"content"` + } `json:"input"` + } + // Internal admission predates the public schema and accepts arbitrary objects. + if err := json.Unmarshal(raw, &p); err != nil { + return nil + } + key := "input:" + strconv.FormatInt(sequence, 10) + if p.Text != nil { + return []Update{{Item: message(turn, key, "user", *p.Text, "completed")}} + } + var updates []Update + for i, input := range p.Input { + if input.Role != "user" || len(input.Content) == 0 { + continue + } + valid := true + for _, c := range input.Content { + if !((c.Type == "input_text" && c.Text != nil) || (c.Type == "input_image" && c.ImageURL != "")) { + valid = false + } + } + if !valid { + continue + } + item := v1.Item{ID: Identity(turn, key+":"+strconv.Itoa(i)), TurnID: turn, Type: "message", Status: "completed", Role: "user", Content: input.Content} + updates = append(updates, Update{Item: item}) + } + return updates +} diff --git a/services/agents-api/internal/items/messages.go b/services/core/internal/items/messages.go similarity index 96% rename from services/agents-api/internal/items/messages.go rename to services/core/internal/items/messages.go index 9f1329137..e33b6269d 100644 --- a/services/agents-api/internal/items/messages.go +++ b/services/core/internal/items/messages.go @@ -6,8 +6,8 @@ import ( "errors" "slices" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/core/internal/items/messages_test.go b/services/core/internal/items/messages_test.go new file mode 100644 index 000000000..eb6e8acf2 --- /dev/null +++ b/services/core/internal/items/messages_test.go @@ -0,0 +1,94 @@ +package items + +import ( + "encoding/json" + "strings" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +const testTurn = "bff31a40-9a63-4a49-aebe-89dfe9dd5268" + +func TestMessageSnapshotsReplaceDeltasAndDoNotRegress(t *testing.T) { + var previous v1.Item + for _, event := range []struct{ kind, body string }{ + {"output_message", `{"id":"native","status":"in_progress","phase":"commentary"}`}, + {"delta", `{"item_id":"native","delta":"draft"}`}, + {"output_message", `{"id":"native","status":"completed","phase":"final_answer","text":"Revised answer"}`}, + {"delta", `{"item_id":"native","delta":"late"}`}, + {"output_message", `{"id":"native","status":"in_progress"}`}, + } { + updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) + if err != nil { + t.Fatal(err) + } + previous = mustMerge(t, updates[0], previous) + } + if previous.Status != "completed" || previous.Phase != "final_answer" || *previous.Content[0].Text != "Revised answer" { + t.Fatalf("%+v", previous) + } + raw, _ := json.Marshal(previous) + if strings.Contains(string(raw), "native") { + t.Fatal("native identity leaked") + } +} + +func TestLegacyDoneDoesNotConfirmSuccessfulAnswer(t *testing.T) { + var previous v1.Item + for _, event := range []struct{ kind, body string }{ + {"delta", `{"delta":"partial answer"}`}, + {"error", `{"error":"provider failure"}`}, + {"done", `{"content":"provider failure"}`}, + {"execution_failed", `{"done":{"content":"provider failure"}}`}, + } { + updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) + if err != nil { + t.Fatal(err) + } + for _, update := range updates { + previous = mustMerge(t, update, previous) + } + } + if previous.Status != "in_progress" || *previous.Content[0].Text != "partial answer" { + t.Fatal(previous) + } + updates, err := Project(testTurn, "execution_completed", 1, []byte(`{"done":{"content":"complete answer"}}`)) + if err != nil { + t.Fatal(err) + } + previous = mustMerge(t, updates[0], previous) + if previous.Status != "completed" || *previous.Content[0].Text != "complete answer" { + t.Fatal(previous) + } +} + +func TestMergePreservesIncomingDeltasAndPreviousSnapshots(t *testing.T) { + var previous v1.Item + fragments := []string{"go ", "go ", "结束"} + for i, fragment := range fragments { + update := Update{Item: message(testTurn, "message:native", "assistant", fragment, "in_progress"), AppendText: true} + before, _ := json.Marshal(previous) + merged := mustMerge(t, update, previous) + after, _ := json.Marshal(previous) + if string(before) != string(after) { + t.Fatal("merge mutated the previous snapshot") + } + if *update.Item.Content[0].Text != fragment { + t.Fatalf("incoming delta changed: got %q, want %q", *update.Item.Content[0].Text, fragment) + } + if *merged.Content[0].Text != strings.Join(fragments[:i+1], "") { + t.Fatalf("accumulated text changed: %+v", merged) + } + previous = merged + } +} + +func mustMerge(t *testing.T, update Update, previous v1.Item) v1.Item { + t.Helper() + item, err := Merge(update, previous) + if err != nil { + t.Fatal(err) + } + return item +} diff --git a/services/agents-api/internal/items/tools.go b/services/core/internal/items/tools.go similarity index 96% rename from services/agents-api/internal/items/tools.go rename to services/core/internal/items/tools.go index 95fb37358..a044cce70 100644 --- a/services/agents-api/internal/items/tools.go +++ b/services/core/internal/items/tools.go @@ -4,8 +4,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func projectTool(turn string, raw json.RawMessage) ([]Update, error) { diff --git a/services/agents-api/internal/items/tools_test.go b/services/core/internal/items/tools_test.go similarity index 100% rename from services/agents-api/internal/items/tools_test.go rename to services/core/internal/items/tools_test.go diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 similarity index 100% rename from services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 rename to services/core/internal/nativeinstaller/assets/bootstrap.ps1 diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.sh b/services/core/internal/nativeinstaller/assets/bootstrap.sh similarity index 100% rename from services/agents-api/internal/nativeinstaller/assets/bootstrap.sh rename to services/core/internal/nativeinstaller/assets/bootstrap.sh diff --git a/services/agents-api/internal/nativeinstaller/bootstrap_test.go b/services/core/internal/nativeinstaller/bootstrap_test.go similarity index 100% rename from services/agents-api/internal/nativeinstaller/bootstrap_test.go rename to services/core/internal/nativeinstaller/bootstrap_test.go diff --git a/services/agents-api/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go similarity index 98% rename from services/agents-api/internal/nativeinstaller/catalog.go rename to services/core/internal/nativeinstaller/catalog.go index bd13dd2dd..6c448b321 100644 --- a/services/agents-api/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -17,7 +17,7 @@ import ( "regexp" "strings" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) //go:embed assets/* diff --git a/services/agents-api/internal/nativeinstaller/catalog_test.go b/services/core/internal/nativeinstaller/catalog_test.go similarity index 98% rename from services/agents-api/internal/nativeinstaller/catalog_test.go rename to services/core/internal/nativeinstaller/catalog_test.go index 9b56892c2..5f5f9cf45 100644 --- a/services/agents-api/internal/nativeinstaller/catalog_test.go +++ b/services/core/internal/nativeinstaller/catalog_test.go @@ -9,7 +9,7 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestCatalogRequiresMatchedImmutableArtifacts(t *testing.T) { diff --git a/services/agents-api/internal/oauthrefresh/client.go b/services/core/internal/oauthrefresh/client.go similarity index 100% rename from services/agents-api/internal/oauthrefresh/client.go rename to services/core/internal/oauthrefresh/client.go diff --git a/services/agents-api/internal/oauthrefresh/client_test.go b/services/core/internal/oauthrefresh/client_test.go similarity index 100% rename from services/agents-api/internal/oauthrefresh/client_test.go rename to services/core/internal/oauthrefresh/client_test.go diff --git a/services/agents-api/internal/oauthrefresh/network.go b/services/core/internal/oauthrefresh/network.go similarity index 100% rename from services/agents-api/internal/oauthrefresh/network.go rename to services/core/internal/oauthrefresh/network.go diff --git a/services/agents-api/internal/oauthrefresh/network_test.go b/services/core/internal/oauthrefresh/network_test.go similarity index 100% rename from services/agents-api/internal/oauthrefresh/network_test.go rename to services/core/internal/oauthrefresh/network_test.go diff --git a/services/agents-api/internal/oauthrefresh/types.go b/services/core/internal/oauthrefresh/types.go similarity index 100% rename from services/agents-api/internal/oauthrefresh/types.go rename to services/core/internal/oauthrefresh/types.go diff --git a/services/agents-api/internal/providercontract/operations.go b/services/core/internal/providercontract/operations.go similarity index 100% rename from services/agents-api/internal/providercontract/operations.go rename to services/core/internal/providercontract/operations.go diff --git a/services/agents-api/internal/runtime/gateway.go b/services/core/internal/runtime/gateway.go similarity index 95% rename from services/agents-api/internal/runtime/gateway.go rename to services/core/internal/runtime/gateway.go index e6de36d37..13201fa38 100644 --- a/services/agents-api/internal/runtime/gateway.go +++ b/services/core/internal/runtime/gateway.go @@ -8,7 +8,7 @@ import ( "github.com/go-chi/chi/v5" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" ) type DeviceStore interface { diff --git a/services/core/internal/runtimeenrollment/connection.go b/services/core/internal/runtimeenrollment/connection.go new file mode 100644 index 000000000..4bd3f780d --- /dev/null +++ b/services/core/internal/runtimeenrollment/connection.go @@ -0,0 +1,136 @@ +package runtimeenrollment + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type ConnectionStore interface { + AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) + GetEnvironment(context.Context, string, string) (store.Environment, error) + GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) + GetDeviceCredential(context.Context, string) (device.Credential, bool, error) +} + +// ConnectionHandler observes an existing binding without enrollment or execution. +// Executor authority never grants access to the public Session API. +func ConnectionHandler(s ConnectionStore, registry *gateway.Registry) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + fail := func(status int) { http.Error(w, http.StatusText(status), status) } + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + fail(http.StatusMethodNotAllowed) + return + } + authorization := strings.Fields(r.Header.Get("Authorization")) + if len(authorization) != 2 || !strings.EqualFold(authorization[0], "Bearer") { + fail(http.StatusUnauthorized) + return + } + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(query) != 1 || len(query["environment_id"]) != 1 || query.Get("environment_id") == "" { + fail(http.StatusBadRequest) + return + } + environment := query.Get("environment_id") + digest := device.HashCredential(authorization[1]) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + connected, err := RuntimeConnected(ctx, s, registry, environment, digest) + switch { + case errors.Is(err, store.ErrNotFound): + fail(http.StatusUnauthorized) + case errors.Is(err, store.ErrDeviceBindingConflict): + fail(http.StatusConflict) + case err != nil: + fail(http.StatusServiceUnavailable) + default: + status := "disconnected" + if connected { + status = "connected" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + EnvironmentID string `json:"environment_id"` + Status string `json:"status"` + }{environment, status}) + } + }) +} + +// RuntimeConnected observes current executor authority and a matching open peer. +// It rechecks authority after the peer; callers must not supply a stale transaction. +func RuntimeConnected(ctx context.Context, s ConnectionStore, registry *gateway.Registry, environment, digest string) (bool, error) { + tenant, err := s.AuthenticateEnvironmentExecutor(ctx, environment, digest) + if err != nil { + return false, err + } + current, err := s.GetEnvironment(ctx, tenant, environment) + if err != nil { + return false, err + } + if current.Status == "failed" || current.Status == "expired" { + return false, store.ErrNotFound + } + bound, err := s.GetSessionDevice(ctx, tenant, current.SessionID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + if bound.EnvironmentID != environment { + return false, store.ErrDeviceBindingConflict + } + credential, found, err := s.GetDeviceCredential(ctx, bound.ID) + if err != nil { + return false, err + } + if !found { + return false, store.ErrNotFound + } + if credential.CredentialHash != digest { + return false, store.ErrDeviceBindingConflict + } + if registry == nil { + return false, nil + } + peer, err := registry.LookupDevice(bound.ID) + if errors.Is(err, gateway.ErrDeviceNotRegistered) { + return false, nil + } + if err != nil { + return false, err + } + if current.Status != "connected" || peer.IsClosed() || !peer.AuthenticatedWith(digest) { + return false, nil + } + // Recheck authority after reading the socket; rotation/revocation never inherits + // the connected observation of a socket authenticated with the former key. + if _, err = s.AuthenticateEnvironmentExecutor(ctx, environment, digest); err != nil { + return false, err + } + // The executor key can remain valid while the device itself is revoked. + // Recheck the shared authority view too, including Environment retirement. + credential, found, err = s.GetDeviceCredential(ctx, bound.ID) + if err != nil { + return false, err + } + if !found { + return false, store.ErrNotFound + } + if credential.CredentialHash != digest { + return false, store.ErrDeviceBindingConflict + } + return !peer.IsClosed() && peer.AuthenticatedWith(digest), nil +} diff --git a/services/core/internal/runtimeenrollment/connection_test.go b/services/core/internal/runtimeenrollment/connection_test.go new file mode 100644 index 000000000..e1c1d5a41 --- /dev/null +++ b/services/core/internal/runtimeenrollment/connection_test.go @@ -0,0 +1,168 @@ +package runtimeenrollment + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/gorilla/websocket" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type connectionStub struct { + err error + calls int +} + +func (s *connectionStub) AuthenticateEnvironmentExecutor(_ context.Context, environment, digest string) (string, error) { + s.calls++ + if environment != "environment" || digest != device.HashCredential("test-key") { + return "", store.ErrNotFound + } + return "tenant", s.err +} +func (*connectionStub) GetEnvironment(context.Context, string, string) (store.Environment, error) { + return store.Environment{ID: "environment", SessionID: "session", Status: "pending"}, nil +} +func (*connectionStub) GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) { + return store.ExecutionDevice{}, store.ErrNotFound +} +func (*connectionStub) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { + panic("unbound lookup") +} + +func TestConnectionReadContract(t *testing.T) { + for _, tc := range []struct { + method, query, bearer string + err error + code, calls int + }{ + {"GET", "environment_id=environment", "Bearer test-key", nil, 200, 1}, + {"GET", "environment_id=environment", "", nil, 401, 0}, + {"POST", "environment_id=environment", "Bearer test-key", nil, 405, 0}, + {"GET", "environment_id=environment&environment_id=other", "Bearer test-key", nil, 400, 0}, + {"GET", "environment_id=environment&other=1", "Bearer test-key", nil, 400, 0}, + {"GET", "environment_id=%zz", "Bearer test-key", nil, 400, 0}, + {"GET", "environment_id=environment", "Bearer test-key", store.ErrNotFound, 401, 1}, + {"GET", "environment_id=environment", "Bearer test-key", errors.New("private detail"), 503, 1}, + } { + s := &connectionStub{err: tc.err} + req := httptest.NewRequest(tc.method, "/api/v1/agent-daemon/connection?"+tc.query, nil) + req.Header.Set("Authorization", tc.bearer) + res := httptest.NewRecorder() + ConnectionHandler(s, gateway.NewRegistry()).ServeHTTP(res, req) + if res.Code != tc.code || s.calls != tc.calls || res.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("%s %s: %d, %d calls", tc.method, tc.query, res.Code, s.calls) + } + if strings.Contains(res.Body.String(), "private") || strings.Contains(res.Body.String(), "test-key") { + t.Fatal("private data exposed") + } + if res.Code == 200 && res.Body.String() != `{"environment_id":"environment","status":"disconnected"}`+"\n" { + t.Fatal("unexpected response", res.Body.String()) + } + } +} + +// A real gateway peer captures its digest at HTTP upgrade. The test store makes +// authority changes at the deterministic post-peer recheck, without timing sleeps. +type liveConnectionStore struct { + digest string + authCalls int + credentialCalls int + revokeAtRecheck bool + deviceRevokedAtRecheck bool + recheckError error + credentialRecheckError error +} + +func (s *liveConnectionStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { + s.authCalls++ + if s.authCalls == 2 { + if s.recheckError != nil { + return "", s.recheckError + } + if s.revokeAtRecheck { + return "", store.ErrNotFound + } + } + return "tenant", nil +} +func (s *liveConnectionStore) GetEnvironment(context.Context, string, string) (store.Environment, error) { + return store.Environment{ID: "environment", SessionID: "session", Status: "connected"}, nil +} +func (s *liveConnectionStore) GetSessionDevice(context.Context, string, string) (store.ExecutionDevice, error) { + return store.ExecutionDevice{ID: "device", EnvironmentID: "environment"}, nil +} +func (s *liveConnectionStore) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { + s.credentialCalls++ + if s.authCalls >= 2 && s.credentialRecheckError != nil { + return device.Credential{}, false, s.credentialRecheckError + } + if s.deviceRevokedAtRecheck && s.authCalls >= 2 { + return device.Credential{}, false, nil + } + return device.Credential{ID: "device", WorkspaceID: "tenant", Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: s.digest}, true, nil +} +func TestRuntimeConnectedCurrentAuthorityAfterPeer(t *testing.T) { + for _, name := range []string{"connected", "rotated before read", "revoked after peer", "device revoked after peer", "retired after peer", "store error after peer", "device store error after peer", "closed", "no registry"} { + t.Run(name, func(t *testing.T) { + digest := device.HashCredential("fixture-key") + s := &liveConnectionStore{digest: digest} + registry := gateway.NewRegistry() + handler := gateway.NewHandler(gateway.HandlerConfig{Registry: registry, Authenticator: gateway.NewAuthenticator(s)}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + defer server.Close() + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+"?device_id=device&version="+proto.Version, http.Header{"Authorization": {"Bearer fixture-key"}}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + // Wait for the actual registration, not merely the transport upgrade. + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + peer, err := registry.WaitForDevice(ctx, "device", time.Second) + if err != nil { + t.Fatal(err) + } + defer peer.Close("test complete") + s.authCalls = 0 + var wantErr error + want := name == "connected" + switch name { + case "rotated before read": + s.digest = device.HashCredential("new-key") + digest = s.digest + case "revoked after peer": + s.revokeAtRecheck = true + wantErr = store.ErrNotFound + case "device revoked after peer", "retired after peer": + s.deviceRevokedAtRecheck = true + wantErr = store.ErrNotFound + case "store error after peer": + s.recheckError = errors.New("database unavailable") + wantErr = s.recheckError + case "device store error after peer": + s.credentialRecheckError = errors.New("device authority unavailable") + wantErr = s.credentialRecheckError + case "closed": + peer.Close("closed before observation") + case "no registry": + registry = nil + } + connected, err := RuntimeConnected(t.Context(), s, registry, "environment", digest) + if connected != want || !errors.Is(err, wantErr) { + t.Fatalf("connected=%v err=%v", connected, err) + } + if name == "connected" && s.authCalls != 2 { + t.Fatal("post-peer authority was not checked") + } + }) + } +} diff --git a/services/agents-api/internal/runtimeenrollment/enrollment.go b/services/core/internal/runtimeenrollment/enrollment.go similarity index 94% rename from services/agents-api/internal/runtimeenrollment/enrollment.go rename to services/core/internal/runtimeenrollment/enrollment.go index a220e6a3b..f7b970af1 100644 --- a/services/agents-api/internal/runtimeenrollment/enrollment.go +++ b/services/core/internal/runtimeenrollment/enrollment.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type EnrollmentStore interface { diff --git a/services/agents-api/internal/runtimeenrollment/enrollment_test.go b/services/core/internal/runtimeenrollment/enrollment_test.go similarity index 94% rename from services/agents-api/internal/runtimeenrollment/enrollment_test.go rename to services/core/internal/runtimeenrollment/enrollment_test.go index 96f1417f3..a216c606d 100644 --- a/services/agents-api/internal/runtimeenrollment/enrollment_test.go +++ b/services/core/internal/runtimeenrollment/enrollment_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type enrollmentStub struct { diff --git a/services/agents-api/internal/runtimehistory/postgresreader/aggregate.go b/services/core/internal/runtimehistory/postgresreader/aggregate.go similarity index 98% rename from services/agents-api/internal/runtimehistory/postgresreader/aggregate.go rename to services/core/internal/runtimehistory/postgresreader/aggregate.go index 63c4b3239..fae764a78 100644 --- a/services/agents-api/internal/runtimehistory/postgresreader/aggregate.go +++ b/services/core/internal/runtimehistory/postgresreader/aggregate.go @@ -7,8 +7,8 @@ import ( "sort" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/runtimehistory/postgresreader/aggregate_test.go b/services/core/internal/runtimehistory/postgresreader/aggregate_test.go similarity index 98% rename from services/agents-api/internal/runtimehistory/postgresreader/aggregate_test.go rename to services/core/internal/runtimehistory/postgresreader/aggregate_test.go index 50b15b11c..9108c8ea5 100644 --- a/services/agents-api/internal/runtimehistory/postgresreader/aggregate_test.go +++ b/services/core/internal/runtimehistory/postgresreader/aggregate_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/google/uuid" ) diff --git a/services/core/internal/runtimehistory/postgresreader/exporter.go b/services/core/internal/runtimehistory/postgresreader/exporter.go new file mode 100644 index 000000000..ef4b66c38 --- /dev/null +++ b/services/core/internal/runtimehistory/postgresreader/exporter.go @@ -0,0 +1,101 @@ +package postgresreader + +import ( + "context" + "errors" + "math" + "regexp" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/google/uuid" +) + +var providerPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) + +func (r *Reader) Export(ctx context.Context, record runtimeobs.ExportRecord) error { + if record.CollectionSource != runtimeobs.CollectionSourcePeriodic { + return nil + } + if err := validateRecord(record); err != nil { + return err + } + if record.ResolvedAt.Before(r.now().Add(-retention)) { + return nil + } + // Counters without a provider incarnation cannot safely participate in rates. + if record.Sample != nil && record.Sample.StartedAt == nil { + value := *record.Sample + value.CPUUsageSecondsTotal = nil + value.CPUCapacityCores = nil + value.CPUUtilizationRatio = nil + value.MemoryUsageBytes = nil + value.MemoryLimitBytes = nil + record.Sample = &value + } + ctx, cancel := context.WithTimeout(ctx, r.queryTimeout) + defer cancel() + if err := r.store.InsertRuntimeHistorySample(ctx, record); err != nil { + return errors.New("persist Runtime history") + } + return nil +} + +func validateRecord(record runtimeobs.ExportRecord) error { + invalid := errors.New("invalid Runtime history sample") + for _, value := range []string{record.TenantID, record.SessionID, record.EnvironmentID} { + if !validID(value) { + return invalid + } + } + if record.AllocationID != "" && !validID(record.AllocationID) { + return invalid + } + if record.Mode != runtimeobs.ModeManaged || record.CollectionSource != runtimeobs.CollectionSourcePeriodic || !validTime(record.ResolvedAt) || record.ProviderType != "" && !providerPattern.MatchString(record.ProviderType) { + return invalid + } + if record.Status != runtimeobs.StatusObserved && record.Status != runtimeobs.StatusUnavailable && record.Status != runtimeobs.StatusUnsupported { + return invalid + } + if (record.Status == runtimeobs.StatusObserved) != (record.Sample != nil) { + return invalid + } + if value := record.Sample; value != nil { + if !validTime(value.ObservedAt) || value.ObservedAt.After(record.ResolvedAt) { + return invalid + } + if value.StartedAt != nil && (!validTime(*value.StartedAt) || value.StartedAt.After(value.ObservedAt) || record.AllocationID == "" || record.ProviderType == "") { + return invalid + } + if value.CPUUsageSecondsTotal != nil && (!validFloat(*value.CPUUsageSecondsTotal) || *value.CPUUsageSecondsTotal < 0) { + return invalid + } + if value.CPUCapacityCores != nil && (!validFloat(*value.CPUCapacityCores) || *value.CPUCapacityCores <= 0) { + return invalid + } + if value.CPUUtilizationRatio != nil && (!validFloat(*value.CPUUtilizationRatio) || *value.CPUUtilizationRatio < 0) { + return invalid + } + if value.MemoryUsageBytes != nil && *value.MemoryUsageBytes > maxSafeInteger { + return invalid + } + if value.MemoryLimitBytes != nil && (*value.MemoryLimitBytes == 0 || *value.MemoryLimitBytes > maxSafeInteger) { + return invalid + } + } + if value := record.TokenUsage; value != nil && (value.InputTokens > maxSafeInteger || value.OutputTokens > maxSafeInteger) { + return invalid + } + return nil +} + +const maxSafeInteger = uint64(1<<53 - 1) + +func validID(value string) bool { + parsed, err := uuid.Parse(value) + return err == nil && parsed != uuid.Nil && parsed.String() == value +} +func validTime(value time.Time) bool { + return !value.IsZero() && value.UnixNano() >= 0 && value.Equal(time.Unix(0, value.UnixNano())) +} +func validFloat(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } diff --git a/services/agents-api/internal/runtimehistory/postgresreader/reader.go b/services/core/internal/runtimehistory/postgresreader/reader.go similarity index 97% rename from services/agents-api/internal/runtimehistory/postgresreader/reader.go rename to services/core/internal/runtimehistory/postgresreader/reader.go index c67bd34c8..6c8672482 100644 --- a/services/agents-api/internal/runtimehistory/postgresreader/reader.go +++ b/services/core/internal/runtimehistory/postgresreader/reader.go @@ -5,8 +5,8 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/runtimehistory/postgresreader/reader_test.go b/services/core/internal/runtimehistory/postgresreader/reader_test.go similarity index 98% rename from services/agents-api/internal/runtimehistory/postgresreader/reader_test.go rename to services/core/internal/runtimehistory/postgresreader/reader_test.go index a3a72c30f..e2fc8b28a 100644 --- a/services/agents-api/internal/runtimehistory/postgresreader/reader_test.go +++ b/services/core/internal/runtimehistory/postgresreader/reader_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" ) const ( diff --git a/services/agents-api/internal/runtimehistory/service.go b/services/core/internal/runtimehistory/service.go similarity index 100% rename from services/agents-api/internal/runtimehistory/service.go rename to services/core/internal/runtimehistory/service.go diff --git a/services/agents-api/internal/runtimehistory/service_test.go b/services/core/internal/runtimehistory/service_test.go similarity index 100% rename from services/agents-api/internal/runtimehistory/service_test.go rename to services/core/internal/runtimehistory/service_test.go diff --git a/services/core/internal/runtimehistory/storeresolver/resolver.go b/services/core/internal/runtimehistory/storeresolver/resolver.go new file mode 100644 index 000000000..f2db2fa84 --- /dev/null +++ b/services/core/internal/runtimehistory/storeresolver/resolver.go @@ -0,0 +1,48 @@ +package storeresolver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type environmentStore interface { + GetSessionEnvironment(context.Context, string, string) (store.Environment, error) +} + +type Resolver struct{ store environmentStore } + +func NewResolver(value environmentStore) (*Resolver, error) { + if value == nil { + return nil, errors.New("Runtime history store is required") + } + return &Resolver{store: value}, nil +} + +// ResolveRuntimeHistoryScope authorizes the Session through the Core store and +// returns only durable Core identity. It intentionally does not resolve a +// current allocation: retained history may contain earlier allocations. The +// Reader keeps each durable allocation as one continuous series. +func (r *Resolver) ResolveRuntimeHistoryScope(ctx context.Context, tenantID, sessionID string) (runtimehistory.Scope, error) { + environment, err := r.store.GetSessionEnvironment(ctx, tenantID, sessionID) + if err != nil { + return runtimehistory.Scope{}, fmt.Errorf("resolve Runtime history Environment: %w", err) + } + if environment.TenantID != tenantID || environment.SessionID != sessionID { + return runtimehistory.Scope{}, errors.New("Runtime history Environment does not match resolved ownership") + } + var configuration struct { + Type string `json:"type"` + } + if json.Unmarshal(environment.Configuration, &configuration) != nil || configuration.Type == "" { + return runtimehistory.Scope{}, errors.New("invalid stored Runtime history environment configuration") + } + if configuration.Type != "openai_hosted" { + return runtimehistory.Scope{}, runtimehistory.ErrUnsupported + } + return runtimehistory.Scope{TenantID: tenantID, SessionID: sessionID, EnvironmentID: environment.ID}, nil +} diff --git a/services/core/internal/runtimehistory/storeresolver/resolver_test.go b/services/core/internal/runtimehistory/storeresolver/resolver_test.go new file mode 100644 index 000000000..033c19bb4 --- /dev/null +++ b/services/core/internal/runtimehistory/storeresolver/resolver_test.go @@ -0,0 +1,74 @@ +package storeresolver + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type resolverStore struct { + environment store.Environment + err error + calls int +} + +func (s *resolverStore) GetSessionEnvironment(context.Context, string, string) (store.Environment, error) { + s.calls++ + return s.environment, s.err +} + +func TestResolverAuthorizesManagedSessionWithoutSelectingCurrentAllocation(t *testing.T) { + backend := &resolverStore{environment: store.Environment{ + ID: environmentID, TenantID: tenantID, SessionID: sessionID, + Configuration: []byte(`{"type":"openai_hosted"}`), + }} + resolver, err := NewResolver(backend) + if err != nil { + t.Fatal(err) + } + scope, err := resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID) + if err != nil { + t.Fatal(err) + } + if scope != (runtimehistory.Scope{TenantID: tenantID, SessionID: sessionID, EnvironmentID: environmentID}) || backend.calls != 1 { + t.Fatalf("unexpected Runtime history scope: %+v calls=%d", scope, backend.calls) + } +} + +func TestResolverPreservesTenantScopedNotFound(t *testing.T) { + backend := &resolverStore{err: store.ErrNotFound} + resolver, err := NewResolver(backend) + if err != nil { + t.Fatal(err) + } + _, err = resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("tenant-scoped not found was not preserved: %v", err) + } +} + +func TestResolverRejectsUnsupportedOrMismatchedEnvironment(t *testing.T) { + for _, environment := range []store.Environment{ + {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, + {ID: environmentID, TenantID: "55555555-5555-4555-8555-555555555555", SessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, + {ID: environmentID, TenantID: tenantID, SessionID: "66666666-6666-4666-8666-666666666666", Configuration: []byte(`{"type":"openai_hosted"}`)}, + {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":`)}, + } { + resolver, err := NewResolver(&resolverStore{environment: environment}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.ResolveRuntimeHistoryScope(t.Context(), tenantID, sessionID); err == nil { + t.Fatalf("unsafe Runtime history Environment accepted: %+v", environment) + } + } +} + +const ( + tenantID = "11111111-1111-4111-8111-111111111111" + sessionID = "22222222-2222-4222-8222-222222222222" + environmentID = "33333333-3333-4333-8333-333333333333" +) diff --git a/services/agents-api/internal/runtimehistory/types.go b/services/core/internal/runtimehistory/types.go similarity index 100% rename from services/agents-api/internal/runtimehistory/types.go rename to services/core/internal/runtimehistory/types.go diff --git a/services/agents-api/internal/runtimeobs/exporter.go b/services/core/internal/runtimeobs/exporter.go similarity index 100% rename from services/agents-api/internal/runtimeobs/exporter.go rename to services/core/internal/runtimeobs/exporter.go diff --git a/services/agents-api/internal/runtimeobs/exporter_independence_test.go b/services/core/internal/runtimeobs/exporter_independence_test.go similarity index 100% rename from services/agents-api/internal/runtimeobs/exporter_independence_test.go rename to services/core/internal/runtimeobs/exporter_independence_test.go diff --git a/services/agents-api/internal/runtimeobs/identity.go b/services/core/internal/runtimeobs/identity.go similarity index 100% rename from services/agents-api/internal/runtimeobs/identity.go rename to services/core/internal/runtimeobs/identity.go diff --git a/services/agents-api/internal/runtimeobs/operations_fixture_test.go b/services/core/internal/runtimeobs/operations_fixture_test.go similarity index 95% rename from services/agents-api/internal/runtimeobs/operations_fixture_test.go rename to services/core/internal/runtimeobs/operations_fixture_test.go index 1ac5df001..2bb1ccd06 100644 --- a/services/agents-api/internal/runtimeobs/operations_fixture_test.go +++ b/services/core/internal/runtimeobs/operations_fixture_test.go @@ -2,7 +2,7 @@ package runtimeobs import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" ) func (*fixedSource) ProviderOperations() providercontract.Operations { diff --git a/services/core/internal/runtimeobs/operations_test.go b/services/core/internal/runtimeobs/operations_test.go new file mode 100644 index 000000000..179c6e6b3 --- /dev/null +++ b/services/core/internal/runtimeobs/operations_test.go @@ -0,0 +1,74 @@ +package runtimeobs + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "testing" + "time" +) + +type failingBatchSource struct { + *fixedSource + batchErr error + batches int +} + +func (*failingBatchSource) ProviderOperations() providercontract.Operations { + return providercontract.Operations{"Observe": {State: providercontract.Supported}, "ObserveBatch": {State: providercontract.Supported}} +} +func (s *failingBatchSource) ObserveBatch(context.Context, []Target) ([]BatchResult, error) { + s.batches++ + return nil, s.batchErr +} +func TestBatchFallbackRequiresExplicitSafeUnsupported(t *testing.T) { + for _, test := range []struct { + name string + err error + fallback bool + }{ + {"unsupported", &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "native_batch_not_supported"}, true}, + {"unavailable", ErrUnavailable, false}, + {"timeout", context.DeadlineExceeded, false}, + {"failure", errors.New("provider failed"), false}, + {"bare unsupported", providercontract.ErrUnsupported, false}, + {"unsafe unsupported", &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "private endpoint / key"}, false}, + {"wrong operation", &providercontract.UnsupportedError{Operation: "Observe", Reason: "not_supported"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + now := time.Now() + ratio := 0.5 + source := &failingBatchSource{fixedSource: &fixedSource{sample: Sample{ObservedAt: now, CPUUtilizationRatio: &ratio}}, batchErr: test.err} + target := Target{EnvironmentID: "environment", Mode: ModeManaged, Instance: Instance{AllocationID: "allocation", ProviderKey: "provider", AllocationState: "running"}} + service, err := NewService(fixedResolver{target: target}, map[string]Source{"provider": source}) + if err != nil { + t.Fatal(err) + } + observations, errs := service.ObserveSessions(t.Context(), []SessionIdentity{{TenantID: "tenant", SessionID: "session"}}, PageOptions{}) + if source.batches != 1 || (source.calls == 1) != test.fallback { + t.Fatalf("batch=%d single=%d", source.batches, source.calls) + } + if test.fallback && (errs[0] != nil || observations[0].Status != StatusObserved) { + t.Fatal(observations, errs) + } + }) + } +} + +type unsupportedObservation struct{ *fixedSource } + +func (*unsupportedObservation) ProviderOperations() providercontract.Operations { + return providercontract.Operations{"Observe": {State: providercontract.Unsupported, Reason: "native_metrics_not_supported"}, "ObserveBatch": {State: providercontract.Unsupported, Reason: "native_metrics_not_supported"}} +} +func TestUnsupportedObservationIsNotUnavailable(t *testing.T) { + source := &unsupportedObservation{&fixedSource{}} + target := Target{EnvironmentID: "environment", Mode: ModeManaged, Instance: Instance{AllocationID: "allocation", ProviderKey: "provider", AllocationState: "running"}} + service, err := NewService(fixedResolver{target: target}, map[string]Source{"provider": source}) + if err != nil { + t.Fatal(err) + } + observation, err := service.ObserveSession(t.Context(), "tenant", "session") + if err != nil || observation.Status != StatusUnsupported || observation.Reason != "native_metrics_not_supported" || source.calls != 0 { + t.Fatal(observation, err, source.calls) + } +} diff --git a/services/core/internal/runtimeobs/otlpexporter/exporter.go b/services/core/internal/runtimeobs/otlpexporter/exporter.go new file mode 100644 index 000000000..f4771a059 --- /dev/null +++ b/services/core/internal/runtimeobs/otlpexporter/exporter.go @@ -0,0 +1,309 @@ +// Package otlpexporter sends sanitized Runtime observation records to an +// operator-configured OTLP/HTTP metrics endpoint. It is optional history +// transport, not Runtime execution or lifecycle authority. +package otlpexporter + +import ( + "context" + "errors" + "math" + "regexp" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/sdk/instrumentation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" +) + +const scopeName = "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/otlpexporter" + +const ( + CPUUsageName = "agents.runtime.cpu.usage" + CPUCapacityName = "agents.runtime.cpu.capacity" + CPUUtilizationName = "agents.runtime.cpu.utilization" + MemoryUsageName = "agents.runtime.memory.usage" + MemoryLimitName = "agents.runtime.memory.limit" + SampleName = "agents.runtime.sample" + SampleDurationName = "agents.runtime.sample.duration" + TokenInputName = "agents.session.tokens.input" + TokenOutputName = "agents.session.tokens.output" +) + +var safeLabelPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +type Config struct { + Endpoint string + Headers map[string]string + Insecure bool + RequestTimeout time.Duration +} + +type metricClient interface { + Export(context.Context, *metricdata.ResourceMetrics) error + Shutdown(context.Context) error +} + +type Exporter struct { + client metricClient + resource *resource.Resource +} + +func New(ctx context.Context, config Config) (*Exporter, error) { + if config.Endpoint == "" { + return nil, errors.New("Runtime history OTLP endpoint is required") + } + options := []otlpmetrichttp.Option{ + otlpmetrichttp.WithEndpointURL(config.Endpoint), + otlpmetrichttp.WithHeaders(config.Headers), + otlpmetrichttp.WithRetry(otlpmetrichttp.RetryConfig{Enabled: true}), + } + if config.Insecure { + options = append(options, otlpmetrichttp.WithInsecure()) + } + if config.RequestTimeout > 0 { + options = append(options, otlpmetrichttp.WithTimeout(config.RequestTimeout)) + } + client, err := otlpmetrichttp.New(ctx, options...) + if err != nil { + return nil, errors.New("cannot initialize Runtime history OTLP exporter") + } + return newWithClient(client), nil +} + +func newWithClient(client metricClient) *Exporter { + return &Exporter{ + client: client, + resource: resource.NewSchemaless( + attribute.String("service.name", "oac-core"), + attribute.String("service.namespace", "oac"), + ), + } +} + +func (e *Exporter) Export(ctx context.Context, record runtimeobs.ExportRecord) error { + metrics, err := recordMetrics(record) + if err != nil { + return err + } + return e.client.Export(ctx, &metricdata.ResourceMetrics{ + Resource: e.resource, + ScopeMetrics: []metricdata.ScopeMetrics{{ + Scope: instrumentation.Scope{Name: scopeName}, + Metrics: metrics, + }}, + }) +} + +func (e *Exporter) Close(ctx context.Context) error { + return e.client.Shutdown(ctx) +} + +func recordMetrics(record runtimeobs.ExportRecord) ([]metricdata.Metrics, error) { + if err := validateRecord(record); err != nil { + return nil, err + } + attributes := recordAttributes(record) + result := []metricdata.Metrics{deltaCountMetric(SampleName, "Validated Runtime observation results", record.ResolvedAt, attributes)} + if record.SourceDuration > 0 { + result = append(result, durationMetric(record, attributes)) + } + if record.TokenUsage != nil { + result = append(result, + integerGaugeMetric(TokenInputName, "Cumulative measured Session input tokens", "{token}", int64(record.TokenUsage.InputTokens), record.ResolvedAt, attributes), + integerGaugeMetric(TokenOutputName, "Cumulative measured Session output tokens", "{token}", int64(record.TokenUsage.OutputTokens), record.ResolvedAt, attributes), + ) + } + if record.Sample == nil { + return result, nil + } + + sample := record.Sample + // Resource values are meaningful only within a provider-qualified compute + // incarnation. Keep the coverage result, but never let an unfenced sample + // collapse CPU or memory points across Runtime restarts. + if sample.StartedAt == nil { + return result, nil + } + if sample.CPUUsageSecondsTotal != nil { + result = append(result, cumulativeMetric( + CPUUsageName, "Cumulative CPU time consumed by the Runtime incarnation", "s", + *sample.CPUUsageSecondsTotal, sample.StartedAt, sample.ObservedAt, attributes, + )) + } + if sample.CPUCapacityCores != nil { + result = append(result, gaugeMetric(CPUCapacityName, "Configured Runtime CPU capacity", "{core}", *sample.CPUCapacityCores, sample.ObservedAt, attributes)) + } + if sample.CPUUtilizationRatio != nil { + result = append(result, gaugeMetric(CPUUtilizationName, "Provider-reported share of Runtime CPU capacity", "1", *sample.CPUUtilizationRatio, sample.ObservedAt, attributes)) + } + if sample.MemoryUsageBytes != nil { + result = append(result, gaugeMetric(MemoryUsageName, "Current Runtime memory usage", "By", float64(*sample.MemoryUsageBytes), sample.ObservedAt, attributes)) + } + if sample.MemoryLimitBytes != nil { + result = append(result, gaugeMetric(MemoryLimitName, "Configured Runtime memory limit", "By", float64(*sample.MemoryLimitBytes), sample.ObservedAt, attributes)) + } + return result, nil +} + +func validateRecord(record runtimeobs.ExportRecord) error { + if record.TenantID == "" || record.SessionID == "" || record.ResolvedAt.IsZero() || record.ResolvedAt.Unix() < 0 { + return errors.New("invalid Runtime history export identity") + } + if record.ProviderType != "" && !safeLabelPattern.MatchString(record.ProviderType) { + return errors.New("invalid Runtime history provider type") + } + if record.Reason != "" && !safeLabelPattern.MatchString(record.Reason) { + return errors.New("invalid Runtime history result reason") + } + switch record.Mode { + case runtimeobs.ModeNone, runtimeobs.ModeSelfHosted, runtimeobs.ModeManaged: + default: + return errors.New("invalid Runtime history mode") + } + switch record.Status { + case runtimeobs.StatusObserved: + if record.Sample == nil { + return errors.New("observed Runtime history record has no sample") + } + case runtimeobs.StatusUnavailable, runtimeobs.StatusUnsupported: + if record.Sample != nil { + return errors.New("unobserved Runtime history record has a sample") + } + default: + return errors.New("invalid Runtime history status") + } + switch record.CollectionSource { + case runtimeobs.CollectionSourceOnRead, runtimeobs.CollectionSourcePeriodic: + default: + return errors.New("invalid Runtime history collection source") + } + if record.SourceDuration < 0 { + return errors.New("invalid Runtime history source duration") + } + if record.SourceDuration > 0 && record.ResolvedAt.Add(-record.SourceDuration).Unix() < 0 { + return errors.New("invalid Runtime history source start time") + } + const maxSafeInteger = uint64(1<<53 - 1) + if record.TokenUsage != nil && (record.TokenUsage.InputTokens > maxSafeInteger || record.TokenUsage.OutputTokens > maxSafeInteger) { + return errors.New("invalid Runtime history token usage") + } + if record.Sample == nil { + return nil + } + return validateSample(record.Sample, record.ResolvedAt) +} + +func validateSample(sample *runtimeobs.Sample, resolvedAt time.Time) error { + if sample.ObservedAt.IsZero() || sample.ObservedAt.Unix() < 0 || sample.ObservedAt.After(resolvedAt) { + return errors.New("invalid Runtime history sample time") + } + if sample.StartedAt != nil && (sample.StartedAt.IsZero() || sample.StartedAt.Unix() < 0 || sample.StartedAt.After(sample.ObservedAt)) { + return errors.New("invalid Runtime history start time") + } + if sample.CPUUsageSecondsTotal != nil && (*sample.CPUUsageSecondsTotal < 0 || math.IsNaN(*sample.CPUUsageSecondsTotal) || math.IsInf(*sample.CPUUsageSecondsTotal, 0)) { + return errors.New("invalid Runtime history CPU usage") + } + if sample.CPUCapacityCores != nil && (*sample.CPUCapacityCores <= 0 || math.IsNaN(*sample.CPUCapacityCores) || math.IsInf(*sample.CPUCapacityCores, 0)) { + return errors.New("invalid Runtime history CPU capacity") + } + if sample.CPUUtilizationRatio != nil && (*sample.CPUUtilizationRatio < 0 || math.IsNaN(*sample.CPUUtilizationRatio) || math.IsInf(*sample.CPUUtilizationRatio, 0)) { + return errors.New("invalid Runtime history CPU utilization") + } + const maxSafeInteger = uint64(1<<53 - 1) + if sample.MemoryUsageBytes != nil && *sample.MemoryUsageBytes > maxSafeInteger { + return errors.New("invalid Runtime history memory usage") + } + if sample.MemoryLimitBytes != nil && (*sample.MemoryLimitBytes == 0 || *sample.MemoryLimitBytes > maxSafeInteger) { + return errors.New("invalid Runtime history memory limit") + } + return nil +} + +func recordAttributes(record runtimeobs.ExportRecord) attribute.Set { + values := []attribute.KeyValue{ + attribute.String("agents.tenant.id", record.TenantID), + attribute.String("agents.session.id", record.SessionID), + attribute.String("agents.runtime.mode", string(record.Mode)), + attribute.String("agents.runtime.status", string(record.Status)), + attribute.String("agents.runtime.collection.source", string(record.CollectionSource)), + attribute.Int64("agents.runtime.resolved_at_unix_nano", record.ResolvedAt.UnixNano()), + } + if record.EnvironmentID != "" { + values = append(values, attribute.String("agents.environment.id", record.EnvironmentID)) + } + if record.AllocationID != "" { + values = append(values, attribute.String("agents.runtime.allocation.id", record.AllocationID)) + } + if record.ProviderType != "" { + values = append(values, attribute.String("agents.runtime.provider.type", record.ProviderType)) + } + if record.Reason != "" { + values = append(values, attribute.String("agents.runtime.reason", record.Reason)) + } + if record.Sample != nil && record.Sample.StartedAt != nil { + values = append(values, attribute.Int64("agents.runtime.compute.started_at_unix_nano", record.Sample.StartedAt.UnixNano())) + } + if record.Sample != nil { + values = append(values, attribute.Int64("agents.runtime.observed_at_unix_nano", record.Sample.ObservedAt.UnixNano())) + } + return attribute.NewSet(values...) +} + +func deltaCountMetric(name, description string, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { + return metricdata.Metrics{Name: name, Description: description, Unit: "{result}", Data: metricdata.Sum[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Attributes: attributes, StartTime: observedAt, Time: observedAt, Value: 1}}, + Temporality: metricdata.DeltaTemporality, IsMonotonic: true, + }} +} + +func durationMetric(record runtimeobs.ExportRecord, attributes attribute.Set) metricdata.Metrics { + duration := record.SourceDuration.Seconds() + bounds := []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10} + buckets := make([]uint64, len(bounds)+1) + index := len(bounds) + for i, bound := range bounds { + if duration <= bound { + index = i + break + } + } + buckets[index] = 1 + start := record.ResolvedAt.Add(-record.SourceDuration) + return metricdata.Metrics{Name: SampleDurationName, Description: "Provider Runtime sample duration", Unit: "s", Data: metricdata.Histogram[float64]{ + DataPoints: []metricdata.HistogramDataPoint[float64]{{ + Attributes: attributes, StartTime: start, Time: record.ResolvedAt, Count: 1, + Bounds: bounds, BucketCounts: buckets, Min: metricdata.NewExtrema(duration), Max: metricdata.NewExtrema(duration), Sum: duration, + }}, + Temporality: metricdata.DeltaTemporality, + }} +} + +func cumulativeMetric(name, description, unit string, value float64, startedAt *time.Time, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { + point := metricdata.DataPoint[float64]{Attributes: attributes, Time: observedAt, Value: value} + if startedAt != nil { + point.StartTime = *startedAt + } + return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Sum[float64]{ + DataPoints: []metricdata.DataPoint[float64]{point}, Temporality: metricdata.CumulativeTemporality, IsMonotonic: true, + }} +} + +func gaugeMetric(name, description, unit string, value float64, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { + return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Gauge[float64]{ + DataPoints: []metricdata.DataPoint[float64]{{Attributes: attributes, Time: observedAt, Value: value}}, + }} +} + +func integerGaugeMetric(name, description, unit string, value int64, observedAt time.Time, attributes attribute.Set) metricdata.Metrics { + return metricdata.Metrics{Name: name, Description: description, Unit: unit, Data: metricdata.Gauge[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Attributes: attributes, Time: observedAt, Value: value}}, + }} +} + +var _ sdkmetric.Exporter = (*otlpmetrichttp.Exporter)(nil) +var _ runtimeobs.Exporter = (*Exporter)(nil) diff --git a/services/agents-api/internal/runtimeobs/otlpexporter/exporter_test.go b/services/core/internal/runtimeobs/otlpexporter/exporter_test.go similarity index 99% rename from services/agents-api/internal/runtimeobs/otlpexporter/exporter_test.go rename to services/core/internal/runtimeobs/otlpexporter/exporter_test.go index 333558b40..0974de22c 100644 --- a/services/agents-api/internal/runtimeobs/otlpexporter/exporter_test.go +++ b/services/core/internal/runtimeobs/otlpexporter/exporter_test.go @@ -14,7 +14,7 @@ import ( collectorv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" "google.golang.org/protobuf/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" ) type captureClient struct { diff --git a/services/agents-api/internal/runtimeobs/sample.go b/services/core/internal/runtimeobs/sample.go similarity index 100% rename from services/agents-api/internal/runtimeobs/sample.go rename to services/core/internal/runtimeobs/sample.go diff --git a/services/agents-api/internal/runtimeobs/sampler.go b/services/core/internal/runtimeobs/sampler.go similarity index 100% rename from services/agents-api/internal/runtimeobs/sampler.go rename to services/core/internal/runtimeobs/sampler.go diff --git a/services/agents-api/internal/runtimeobs/sampler_test.go b/services/core/internal/runtimeobs/sampler_test.go similarity index 100% rename from services/agents-api/internal/runtimeobs/sampler_test.go rename to services/core/internal/runtimeobs/sampler_test.go diff --git a/services/agents-api/internal/runtimeobs/service.go b/services/core/internal/runtimeobs/service.go similarity index 99% rename from services/agents-api/internal/runtimeobs/service.go rename to services/core/internal/runtimeobs/service.go index 08188e606..a41f363d7 100644 --- a/services/agents-api/internal/runtimeobs/service.go +++ b/services/core/internal/runtimeobs/service.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "reflect" "regexp" "sync" diff --git a/services/agents-api/internal/runtimeobs/service_test.go b/services/core/internal/runtimeobs/service_test.go similarity index 100% rename from services/agents-api/internal/runtimeobs/service_test.go rename to services/core/internal/runtimeobs/service_test.go diff --git a/services/agents-api/internal/runtimeobs/source.go b/services/core/internal/runtimeobs/source.go similarity index 93% rename from services/agents-api/internal/runtimeobs/source.go rename to services/core/internal/runtimeobs/source.go index fcfb9b1f7..3a31edae9 100644 --- a/services/agents-api/internal/runtimeobs/source.go +++ b/services/core/internal/runtimeobs/source.go @@ -3,7 +3,7 @@ package runtimeobs import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" ) var ( diff --git a/services/core/internal/runtimeobs/storeresolver/resolver.go b/services/core/internal/runtimeobs/storeresolver/resolver.go new file mode 100644 index 000000000..c89ebdc53 --- /dev/null +++ b/services/core/internal/runtimeobs/storeresolver/resolver.go @@ -0,0 +1,158 @@ +package storeresolver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type sessionStore interface { + GetSession(context.Context, string, string) (store.Session, error) + MeasuredSessionUsage(context.Context, string, string) (json.RawMessage, error) + GetRuntimeAllocation(context.Context, string, string) (store.RuntimeAllocation, error) + ListRuntimeObservationSessions(context.Context, string, int) (store.RuntimeObservationSessionPage, error) +} + +type Resolver struct{ store sessionStore } + +func NewResolver(s sessionStore) (*Resolver, error) { + if s == nil { + return nil, errors.New("Runtime observation store is required") + } + return &Resolver{store: s}, nil +} + +func (r *Resolver) Resolve(ctx context.Context, tenantID, sessionID string) (runtimeobs.Target, error) { + session, err := r.store.GetSession(ctx, tenantID, sessionID) + if err != nil { + return runtimeobs.Target{}, fmt.Errorf("resolve Runtime Session: %w", err) + } + var configuration struct { + Environment *struct { + Type string `json:"type"` + } `json:"environment"` + } + if err := json.Unmarshal(session.Configuration, &configuration); err != nil || configuration.Environment == nil { + return runtimeobs.Target{}, errors.New("invalid stored Runtime environment configuration") + } + target := runtimeobs.Target{TenantID: session.TenantID, SessionID: session.ID, Mode: runtimeobs.Mode(configuration.Environment.Type)} + // Telemetry counts measured usage continuously, including active Turns. + // Public Session usage stays null until every root Turn ends measured. + measured, err := r.store.MeasuredSessionUsage(ctx, session.TenantID, session.ID) + if err != nil { + return runtimeobs.Target{}, fmt.Errorf("resolve Runtime Session usage: %w", err) + } + usage, err := decodeTokenUsage(measured) + if err != nil { + return runtimeobs.Target{}, err + } + target.TokenUsage = usage + switch target.Mode { + case runtimeobs.ModeNone: + if session.Environment != nil { + return runtimeobs.Target{}, errors.New("environment:none unexpectedly has a durable Environment") + } + return target, nil + case runtimeobs.ModeSelfHosted: + if session.Environment == nil { + return runtimeobs.Target{}, errors.New("self-hosted Session is missing its Environment") + } + if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { + return runtimeobs.Target{}, errors.New("self-hosted Environment does not match resolved ownership") + } + target.EnvironmentID = session.Environment.ID + return target, nil + case runtimeobs.ModeManaged: + if session.Environment == nil { + return runtimeobs.Target{}, errors.New("managed Session is missing its Environment") + } + if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { + return runtimeobs.Target{}, errors.New("managed Environment does not match resolved ownership") + } + target.EnvironmentID = session.Environment.ID + allocation, err := r.store.GetRuntimeAllocation(ctx, tenantID, target.EnvironmentID) + if errors.Is(err, store.ErrNotFound) { + return target, runtimeobs.ErrUnavailable + } + if err != nil { + return runtimeobs.Target{}, fmt.Errorf("resolve Runtime allocation: %w", err) + } + if allocation.TenantID != tenantID || allocation.SessionID != session.ID || allocation.EnvironmentID != target.EnvironmentID { + return runtimeobs.Target{}, errors.New("Runtime allocation does not match resolved ownership") + } + target.Instance = runtimeobs.Instance{ + AllocationID: allocation.ID, ProviderKey: allocation.ProviderKey, DeviceID: allocation.DeviceID, + AllocationState: allocation.State, AllocationCreatedAt: allocation.CreatedAt, + ComputePhase: allocation.ComputePhase, ProviderState: append(json.RawMessage(nil), allocation.ComputeState...), + } + return target, nil + default: + return runtimeobs.Target{}, errors.New("invalid stored Runtime environment type") + } +} + +type storedTokenUsage struct { + InputTokens *int64 `json:"input_tokens"` + InputTokensDetails *storedInputTokenDetails `json:"input_tokens_details"` + OutputTokens *int64 `json:"output_tokens"` + OutputTokensDetails *storedOutputTokenDetails `json:"output_tokens_details"` + TotalTokens *int64 `json:"total_tokens"` +} + +type storedInputTokenDetails struct { + CachedTokens *int64 `json:"cached_tokens"` +} + +type storedOutputTokenDetails struct { + ReasoningTokens *int64 `json:"reasoning_tokens"` +} + +func decodeTokenUsage(raw json.RawMessage) (*runtimeobs.TokenUsage, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var usage storedTokenUsage + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&usage); err != nil { + return nil, errors.New("invalid stored Session token usage") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, errors.New("invalid stored Session token usage") + } + if usage.InputTokens == nil || usage.OutputTokens == nil || usage.TotalTokens == nil || + usage.InputTokensDetails == nil || usage.InputTokensDetails.CachedTokens == nil || + usage.OutputTokensDetails == nil || usage.OutputTokensDetails.ReasoningTokens == nil { + return nil, errors.New("invalid stored Session token usage") + } + const maxSafeInteger = int64(1<<53 - 1) + values := []int64{*usage.InputTokens, *usage.OutputTokens, *usage.TotalTokens, *usage.InputTokensDetails.CachedTokens, *usage.OutputTokensDetails.ReasoningTokens} + for _, value := range values { + if value < 0 || value > maxSafeInteger { + return nil, errors.New("invalid stored Session token usage") + } + } + if *usage.InputTokens+*usage.OutputTokens != *usage.TotalTokens { + return nil, errors.New("invalid stored Session token usage") + } + return &runtimeobs.TokenUsage{InputTokens: uint64(*usage.InputTokens), OutputTokens: uint64(*usage.OutputTokens)}, nil +} + +func (r *Resolver) ListRuntimeObservationSessions(ctx context.Context, after string, limit int) (runtimeobs.SessionPage, error) { + page, err := r.store.ListRuntimeObservationSessions(ctx, after, limit) + if err != nil { + return runtimeobs.SessionPage{}, err + } + result := runtimeobs.SessionPage{Sessions: make([]runtimeobs.SessionIdentity, 0, len(page.Sessions)), NextCursor: page.NextCursor} + for _, session := range page.Sessions { + result.Sessions = append(result.Sessions, runtimeobs.SessionIdentity{TenantID: session.TenantID, SessionID: session.SessionID}) + } + return result, nil +} diff --git a/services/core/internal/runtimeobs/storeresolver/resolver_test.go b/services/core/internal/runtimeobs/storeresolver/resolver_test.go new file mode 100644 index 000000000..4bc31abce --- /dev/null +++ b/services/core/internal/runtimeobs/storeresolver/resolver_test.go @@ -0,0 +1,220 @@ +package storeresolver + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +type resolverStore struct { + session store.Session + measured json.RawMessage + allocation store.RuntimeAllocation + allocationErr error + page store.RuntimeObservationSessionPage +} + +func (s resolverStore) GetSession(context.Context, string, string) (store.Session, error) { + return s.session, nil +} + +func (s resolverStore) MeasuredSessionUsage(_ context.Context, tenant, session string) (json.RawMessage, error) { + if tenant != s.session.TenantID || session != s.session.ID { + return nil, errors.New("measured usage read for another Session") + } + return s.measured, nil +} + +func (s resolverStore) GetRuntimeAllocation(context.Context, string, string) (store.RuntimeAllocation, error) { + return s.allocation, s.allocationErr +} + +func (s resolverStore) ListRuntimeObservationSessions(context.Context, string, int) (store.RuntimeObservationSessionPage, error) { + return s.page, nil +} + +func TestResolverListsOnlyProviderNeutralSessionIdentity(t *testing.T) { + r, err := NewResolver(resolverStore{page: store.RuntimeObservationSessionPage{ + Sessions: []store.RuntimeObservationSession{{TenantID: "tenant", SessionID: "session"}}, + NextCursor: "session", + }}) + if err != nil { + t.Fatal(err) + } + page, err := r.ListRuntimeObservationSessions(t.Context(), "", 32) + if err != nil || len(page.Sessions) != 1 || page.Sessions[0].TenantID != "tenant" || page.Sessions[0].SessionID != "session" || page.NextCursor != "session" { + t.Fatalf("unexpected observation scan identity: %+v %v", page, err) + } +} + +func TestResolverBindsManagedSessionEnvironmentAndAllocation(t *testing.T) { + r, err := NewResolver(resolverStore{ + session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + measured: []byte(`{"input_tokens":120,"input_tokens_details":{"cached_tokens":20},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":150}`), + allocation: store.RuntimeAllocation{ + ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", + ProviderKey: "provider", DeviceID: "device", ComputePhase: "running", ComputeState: []byte(`{"current":{"name":"sandbox"}}`), + }, + }) + if err != nil { + t.Fatal(err) + } + target, err := r.Resolve(t.Context(), "tenant", "session") + if err != nil { + t.Fatal(err) + } + if target.TenantID != "tenant" || target.SessionID != "session" || target.EnvironmentID != "environment" || target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID != "allocation" || target.Instance.ProviderKey != "provider" || target.Instance.DeviceID != "device" { + t.Fatalf("incorrect managed identity binding: %+v", target) + } + if string(target.Instance.ProviderState) != `{"current":{"name":"sandbox"}}` { + t.Fatalf("provider state was not retained: %s", target.Instance.ProviderState) + } + if target.Instance.ComputePhase != "running" { + t.Fatalf("compute phase was not retained: %s", target.Instance.ComputePhase) + } + if target.TokenUsage == nil || target.TokenUsage.InputTokens != 120 || target.TokenUsage.OutputTokens != 30 { + t.Fatalf("measured Session usage was not retained: %+v", target.TokenUsage) + } +} + +func TestResolverRejectsInvalidCanonicalSessionUsage(t *testing.T) { + for _, usage := range []string{ + `{"input_tokens":2,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":4}`, + `{}`, + `{"total_tokens":0}`, + `{"input_tokens":0,"input_tokens_details":{"cached_tokens":-1},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`, + `{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0,"unknown":0}`, + } { + resolver, err := NewResolver(resolverStore{session: store.Session{ + ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), + }, measured: []byte(usage)}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { + t.Fatalf("invalid Session token usage was accepted: %s", usage) + } + } +} + +func TestResolverKeepsNullCanonicalSessionUsageAbsent(t *testing.T) { + resolver, err := NewResolver(resolverStore{session: store.Session{ + ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), + }, measured: []byte(" \n null \t")}) + if err != nil { + t.Fatal(err) + } + target, err := resolver.Resolve(t.Context(), "tenant", "session") + if err != nil || target.TokenUsage != nil { + t.Fatalf("null Session usage was not kept absent: %+v %v", target.TokenUsage, err) + } +} + +// Telemetry reads measured usage, not the public Session value, which stays +// null while a root Turn runs. +func TestResolverUsesMeasuredRatherThanPublicSessionUsage(t *testing.T) { + measured := `{"input_tokens":7,"input_tokens_details":{"cached_tokens":0},"output_tokens":3,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":10}` + for _, test := range []struct { + public, measured string + want *runtimeobs.TokenUsage + }{ + {"null", measured, &runtimeobs.TokenUsage{InputTokens: 7, OutputTokens: 3}}, + {measured, "null", nil}, + } { + resolver, err := NewResolver(resolverStore{session: store.Session{ + ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"none"}}`), Usage: []byte(test.public), + }, measured: []byte(test.measured)}) + if err != nil { + t.Fatal(err) + } + target, err := resolver.Resolve(t.Context(), "tenant", "session") + if err != nil || (target.TokenUsage == nil) != (test.want == nil) || (test.want != nil && *target.TokenUsage != *test.want) { + t.Fatalf("usage source: %+v %v", target.TokenUsage, err) + } + } +} + +func TestResolverKeepsUnsupportedModesDistinct(t *testing.T) { + for _, tc := range []struct { + mode string + environment *store.Environment + }{ + {mode: "none"}, + {mode: "self_hosted", environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + } { + r, err := NewResolver(resolverStore{session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"` + tc.mode + `"}}`), Environment: tc.environment}}) + if err != nil { + t.Fatal(err) + } + target, err := r.Resolve(t.Context(), "tenant", "session") + if err != nil || string(target.Mode) != tc.mode { + t.Fatalf("mode %s was not resolved accurately: %+v %v", tc.mode, target, err) + } + } +} + +func TestResolverReportsManagedAllocationAsUnavailable(t *testing.T) { + r, err := NewResolver(resolverStore{ + session: store.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + allocationErr: store.ErrNotFound, + }) + if err != nil { + t.Fatal(err) + } + target, err := r.Resolve(t.Context(), "tenant", "session") + if !errors.Is(err, runtimeobs.ErrUnavailable) || target.EnvironmentID != "environment" || target.Mode != runtimeobs.ModeManaged { + t.Fatalf("allocation absence was not preserved: %+v %v", target, err) + } +} + +func TestResolverRejectsMismatchedEnvironmentOwnership(t *testing.T) { + for _, mode := range []string{"self_hosted", "openai_hosted"} { + for _, environment := range []store.Environment{ + {ID: "environment", TenantID: "other", SessionID: "session"}, + {ID: "environment", TenantID: "tenant", SessionID: "other"}, + } { + resolver, err := NewResolver(resolverStore{session: store.Session{ + ID: "session", TenantID: "tenant", + Configuration: []byte(`{"environment":{"type":"` + mode + `"}}`), Environment: &environment, + }}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { + t.Fatalf("mismatched %s Environment accepted: %+v", mode, environment) + } + } + } +} + +func TestResolverRejectsMismatchedAllocationOwnership(t *testing.T) { + base := store.RuntimeAllocation{ + ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", + ProviderKey: "provider", DeviceID: "device", + } + for _, mutate := range []func(*store.RuntimeAllocation){ + func(value *store.RuntimeAllocation) { value.TenantID = "other" }, + func(value *store.RuntimeAllocation) { value.SessionID = "other" }, + func(value *store.RuntimeAllocation) { value.EnvironmentID = "other" }, + } { + allocation := base + mutate(&allocation) + resolver, err := NewResolver(resolverStore{ + session: store.Session{ + ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), + Environment: &store.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}, + }, + allocation: allocation, + }) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(t.Context(), "tenant", "session"); err == nil { + t.Fatalf("mismatched allocation accepted: %+v", allocation) + } + } +} diff --git a/services/agents-api/internal/sandbox/call_fence.go b/services/core/internal/sandbox/call_fence.go similarity index 100% rename from services/agents-api/internal/sandbox/call_fence.go rename to services/core/internal/sandbox/call_fence.go diff --git a/services/core/internal/sandbox/contracttest/provider.go b/services/core/internal/sandbox/contracttest/provider.go new file mode 100644 index 000000000..e72d8b13d --- /dev/null +++ b/services/core/internal/sandbox/contracttest/provider.go @@ -0,0 +1,104 @@ +// Package contracttest runs shared lifecycle assertions against real adapters +// backed by controlled native transports. It is test support only. +package contracttest + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "reflect" + "testing" + "time" +) + +type Fault string + +const ( + TransportFailure Fault = "transport_failure" + Canceled Fault = "canceled_after_dispatch" + ForeignOwnership Fault = "foreign_ownership" + CleanupFailure Fault = "cleanup_failure" +) + +type Scenario struct { + Operation string + Fault Fault +} + +// Fixture exposes the production adapter. Calls records all native requests; +// WantCalls includes legitimate multi-step work before the injected fault. Exact +// comparison detects replay, fallback and implicit cleanup after uncertain Create. +type Fixture struct { + Provider sandbox.SandboxProvider + Bootstrap sandbox.Bootstrap + Calls func() []string + WantCalls []string +} + +// Factory configures native responses. Invoke cancel only after dispatch begins. +type Factory func(t *testing.T, scenario Scenario, cancel context.CancelFunc) Fixture + +// RunFailures requires errors without mandating one native error type. Failed +// or canceled calls must not invent proof of settlement or bootstrap completion. +func RunFailures(t *testing.T, factory Factory) { + t.Helper() + for _, operation := range []string{"create", "inspect", "renew", "kill"} { + faults := []Fault{TransportFailure, Canceled, ForeignOwnership} + if operation == "kill" { + faults = append(faults, CleanupFailure) + } + for _, fault := range faults { + t.Run(operation+"/"+string(fault), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + f := factory(t, Scenario{operation, fault}, cancel) + if err := sandbox.ValidateProvider(f.Provider); err != nil { + t.Fatal(err) + } + var info sandbox.Info + var err error + switch operation { + case "create": + info, err = f.Provider.Create(ctx, f.Bootstrap) + case "inspect": + info, err = f.Provider.GetInfo(ctx, f.Bootstrap.Reference) + case "renew": + info, err = f.Provider.Renew(ctx, f.Bootstrap.Reference) + case "kill": + err = f.Provider.Kill(ctx, f.Bootstrap.Reference) + } + if errors.Is(err, providercontract.ErrUnsupported) { + t.Fatal("required operation returned Unsupported", err) + } + if err == nil { + t.Fatal("native failure was reported as success") + } + if fault == Canceled && ctx.Err() == nil { + t.Fatal("fixture did not cancel the dispatched operation") + } + if info.CreateSettled || info.BootstrapComplete { + t.Fatalf("failed operation invented settlement or bootstrap proof: %+v", info) + } + if info.Reference != (sandbox.Reference{}) && info.Reference != f.Bootstrap.Reference { + t.Fatalf("failure exposed a foreign reference: %+v", info.Reference) + } + if got := f.Calls(); !reflect.DeepEqual(got, f.WantCalls) { + t.Fatalf("native calls = %v, want %v; unexpected replay, mutation or cleanup", got, f.WantCalls) + } + }) + } + } +} + +// AssertObservation checks identity and the expected native state, without +// equating a running process with authenticated Runtime or executor readiness. +func AssertObservation(t *testing.T, got sandbox.Info, err error, reference sandbox.Reference, providerID, state string) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if got.Reference != reference || got.ProviderID == "" || providerID != "" && got.ProviderID != providerID || got.State != state { + t.Fatalf("observation lost identity or native state: %+v", got) + } +} diff --git a/services/agents-api/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go similarity index 100% rename from services/agents-api/internal/sandbox/deployment.go rename to services/core/internal/sandbox/deployment.go diff --git a/services/agents-api/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go similarity index 100% rename from services/agents-api/internal/sandbox/deployment_contract.go rename to services/core/internal/sandbox/deployment_contract.go diff --git a/services/core/internal/sandbox/docker/bootstrap.go b/services/core/internal/sandbox/docker/bootstrap.go new file mode 100644 index 000000000..3fee133a4 --- /dev/null +++ b/services/core/internal/sandbox/docker/bootstrap.go @@ -0,0 +1,54 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "io" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/client" +) + +type entry struct { + name string + content []byte + directory bool +} + +func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap) error { + // Deliver the Runtime-owned connection contract before native work can start. + auth, e := b.RuntimeConnection().Marshal() + if e != nil { + return e + } + if e = copyRuntimeFiles(ctx, p.client, id, "/home", []entry{ + {name: "runtime", directory: true}, {name: "runtime/.oac", directory: true}, + {name: "runtime/runtime-bootstrap.json", content: auth}, + }); e != nil { + return e + } + return copyRuntimeFiles(ctx, p.client, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) +} +func copyRuntimeFiles(ctx context.Context, c *client.Client, id, path string, entries []entry) error { + var content bytes.Buffer + writer := tar.NewWriter(&content) + for _, file := range entries { + header := &tar.Header{Name: file.name, Uid: 1000, Gid: 1000, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg} + if file.directory { + header.Mode = 0700 + header.Typeflag = tar.TypeDir + } + if e := writer.WriteHeader(header); e != nil { + return e + } + if _, e := writer.Write(file.content); e != nil { + return e + } + } + if e := writer.Close(); e != nil { + return e + } + _, e := c.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) + return e +} diff --git a/services/core/internal/sandbox/docker/bootstrap_test.go b/services/core/internal/sandbox/docker/bootstrap_test.go new file mode 100644 index 000000000..31d2845ff --- /dev/null +++ b/services/core/internal/sandbox/docker/bootstrap_test.go @@ -0,0 +1,62 @@ +package docker + +import ( + "archive/tar" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/client" +) + +func TestBootstrapDeliversOnlyPublicConnectionInput(t *testing.T) { + b := sandbox.Bootstrap{CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-secret"} + found := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" || !strings.HasSuffix(r.URL.Path, "/containers/test/archive") { + t.Errorf("unexpected Docker operation %s", r.URL.Path) + } + tr := tar.NewReader(r.Body) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Error(err) + break + } + if strings.Contains(h.Name, "auth.json") { + t.Error("provider wrote Runtime private storage") + } + if h.Name == "runtime/runtime-bootstrap.json" { + found = true + raw, err := io.ReadAll(tr) + if err != nil { + t.Error(err) + } + c, err := runtimebootstrap.Decode(raw) + if err != nil || c != b.RuntimeConnection() || h.Mode != 0600 || h.Uid != 1000 || h.Gid != 1000 { + t.Error("invalid launch input or permissions") + } + } + } + w.WriteHeader(200) + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err := (&Provider{client: c}).bootstrap(t.Context(), "test", b); err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("missing Runtime input") + } +} diff --git a/services/core/internal/sandbox/docker/command.go b/services/core/internal/sandbox/docker/command.go new file mode 100644 index 000000000..546109160 --- /dev/null +++ b/services/core/internal/sandbox/docker/command.go @@ -0,0 +1,91 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "io" + "path" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/client" +) + +// RunCommand is for trusted initialization only. Closing an exec attachment does +// not kill its process. On any uncertain outcome, the caller must reclaim the +// allocation with Kill instead of admitting native work or replaying the command. +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, command sandbox.Command) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + if len(command.Args) == 0 || len(command.Stdin) > sandbox.MaxCommandInputBytes || (command.Directory != "" && !path.IsAbs(command.Directory)) { + return result, sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil { + return result, e + } + if _, ok := ctx.Deadline(); !ok { + return result, sandbox.ErrInvalid + } + exec, e := p.client.ExecCreate(ctx, c.Container.ID, client.ExecCreateOptions{User: "1000:1000", Cmd: command.Args, WorkingDir: command.Directory, AttachStdin: command.Stdin != nil, AttachStdout: true, AttachStderr: true}) + if e != nil { + return result, e + } + attached, e := p.client.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{}) + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + defer attached.Close() + written := make(chan error, 1) + if command.Stdin != nil { + go func() { + _, err := io.Copy(attached.Conn, bytes.NewReader(command.Stdin)) + if err == nil { + err = attached.CloseWrite() + } + written <- err + }() + } else { + written <- nil + } + stdout, stderr := &boundedBuffer{}, &boundedBuffer{} + done := make(chan error, 1) + go func() { _, e := stdcopy.StdCopy(stdout, stderr, attached.Reader); done <- e }() + select { + case e = <-done: + case <-ctx.Done(): + attached.Close() + <-done + e = ctx.Err() + } + if e != nil { + attached.Close() + <-written + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + select { + case e = <-written: + case <-ctx.Done(): + attached.Close() + <-written + e = ctx.Err() + } + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + status, e := p.client.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{}) + if e != nil || status.Running { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + return sandbox.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: status.ExitCode}, nil +} + +type boundedBuffer struct{ bytes.Buffer } + +func (b *boundedBuffer) Write(data []byte) (int, error) { + const max = 1024 * 1024 + if b.Len()+len(data) > max { + return 0, errors.New("initialization output exceeds 1 MiB") + } + return b.Buffer.Write(data) +} diff --git a/services/agents-api/internal/sandbox/docker/container_options.go b/services/core/internal/sandbox/docker/container_options.go similarity index 100% rename from services/agents-api/internal/sandbox/docker/container_options.go rename to services/core/internal/sandbox/docker/container_options.go diff --git a/services/core/internal/sandbox/docker/contract_test.go b/services/core/internal/sandbox/docker/contract_test.go new file mode 100644 index 000000000..09db3fef8 --- /dev/null +++ b/services/core/internal/sandbox/docker/contract_test.go @@ -0,0 +1,140 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +// The script covers only native calls before a fault. Full Docker creation and +// bootstrap remain covered by the existing opt-in lifecycle and recovery tests. +type contractStep struct { + method, path string + status int + body any + fault contracttest.Fault +} + +func dockerContractFixture(t *testing.T, cancel context.CancelFunc, script func(*Provider, sandbox.Reference) []contractStep) contracttest.Fixture { + t.Helper() + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var mu sync.Mutex + var calls []string + var steps []contractStep + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.52") + call := r.Method + " " + path + mu.Lock() + index := len(calls) + calls = append(calls, call) + mu.Unlock() + if index >= len(steps) || call != steps[index].method+" "+steps[index].path { + t.Errorf("unexpected native request: %s", call) + http.Error(w, "unexpected request", 500) + return + } + step := steps[index] + switch step.fault { + case contracttest.Canceled: + cancel() + return + case contracttest.TransportFailure: + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(step.status) + if step.body != nil { + _ = json.NewEncoder(w).Encode(step.body) + } + })) + t.Cleanup(server.Close) + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + p, err := New(c, Config{InstallationID: uuid.NewString(), Image: "sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: "{}"}) + if err != nil { + t.Fatal(err) + } + steps = script(p, b.Reference) + want := make([]string, len(steps)) + for i, step := range steps { + want[i] = step.method + " " + step.path + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), calls...) + }, WantCalls: want} +} + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + return dockerContractFixture(t, cancel, func(p *Provider, r sandbox.Reference) []contractStep { + inspect := "/containers/" + p.name(r) + "/json" + home, environment := "/volumes/"+p.name(r)+"-home", "/volumes/"+p.name(r)+"-environment" + missing := map[string]string{"message": "not found"} + owned := map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "running"}} + foreign := map[string]any{"Labels": map[string]string{"io.oac.tenant": uuid.NewString()}} + if s.Fault == contracttest.ForeignOwnership { + if s.Operation == "kill" { + // Verify every ownership check precedes any deletion, including volumes. + return []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, foreign, ""}} + } + return []contractStep{{http.MethodGet, inspect, 200, map[string]any{"Id": "foreign", "Config": foreign}, ""}} + } + switch s.Operation { + case "create": + return []contractStep{ + {http.MethodGet, inspect, 404, missing, ""}, + {http.MethodGet, home, 404, missing, ""}, + {http.MethodGet, environment, 404, missing, ""}, + {http.MethodPost, "/volumes/create", 0, nil, s.Fault}, + } + case "kill": + volume := map[string]any{"Labels": p.labels(r)} + steps := []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, volume, ""}, {http.MethodGet, environment, 200, volume, ""}} + if s.Fault == contracttest.CleanupFailure { + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 204, nil, ""}, contractStep{http.MethodDelete, home, 500, map[string]string{"message": "cleanup failed"}, ""}) + } + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 0, nil, s.Fault}) + default: + return []contractStep{{http.MethodGet, inspect, 0, nil, s.Fault}} + } + }) + }) +} + +func TestProviderContractObservation(t *testing.T) { + for _, operation := range []string{"inspect", "renew"} { + t.Run(operation, func(t *testing.T) { + f := dockerContractFixture(t, func() {}, func(p *Provider, r sandbox.Reference) []contractStep { + return []contractStep{{http.MethodGet, "/containers/" + p.name(r) + "/json", 200, map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "exited"}}, ""}} + }) + var got sandbox.Info + var err error + if operation == "renew" { + got, err = f.Provider.Renew(t.Context(), f.Bootstrap.Reference) + } else { + got, err = f.Provider.GetInfo(t.Context(), f.Bootstrap.Reference) + } + contracttest.AssertObservation(t, got, err, f.Bootstrap.Reference, "native-owned", "exited") + }) + } +} diff --git a/services/core/internal/sandbox/docker/deployment.go b/services/core/internal/sandbox/docker/deployment.go new file mode 100644 index 000000000..16b5e77ae --- /dev/null +++ b/services/core/internal/sandbox/docker/deployment.go @@ -0,0 +1,30 @@ +package docker + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/client" +) + +// Resource drift blocks managed readiness, but never ownership-based cleanup. +// Resolve the immutable selector through Docker because classic and containerd +// stores may identify the same exported image by different digests. +func (p *Provider) verifyConfiguration(ctx context.Context, actual client.ContainerInspectResult) error { + if p.config.Resources == nil { + return nil + } + resources := p.config.Resources + config := actual.Container.HostConfig + if config == nil || config.NanoCPUs != int64(resources.CPUs)*1000000000 || config.Memory != int64(resources.MemoryMiB)*1024*1024 { + return sandbox.ErrInvalid + } + image, err := p.client.ImageInspect(ctx, p.config.Image) + if err != nil { + return err + } + if image.ID == "" || actual.Container.Image != image.ID { + return sandbox.ErrInvalid + } + return nil +} diff --git a/services/core/internal/sandbox/docker/deployment_test.go b/services/core/internal/sandbox/docker/deployment_test.go new file mode 100644 index 000000000..9df0727d2 --- /dev/null +++ b/services/core/internal/sandbox/docker/deployment_test.go @@ -0,0 +1,116 @@ +package docker + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +func TestManagedDriftRejectsBeforeBootstrapAndRetainsCleanup(t *testing.T) { + for _, drift := range []string{"cpu", "memory", "image"} { + t.Run(drift, func(t *testing.T) { + imageID := "sha256:" + strings.Repeat("a", 64) + present := false + volumes := map[string]map[string]any{} + var created struct { + container.Config + HostConfig container.HostConfig + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.52") + w.Header().Set("Content-Type", "application/json") + switch { + case path == "/containers/create": + _ = json.NewDecoder(r.Body).Decode(&created) + if created.HostConfig.NanoCPUs != 3e9 || created.HostConfig.Memory != 4096*1024*1024 { + t.Error("create did not apply managed limits") + } + present = true + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"Id":"runtime-id"}`) + case strings.HasPrefix(path, "/containers/") && strings.HasSuffix(path, "/json") && present: + host := created.HostConfig + actualImage := imageID + switch drift { + case "cpu": + host.NanoCPUs = 1e9 + case "memory": + host.Memory = 1024 * 1024 * 1024 + case "image": + actualImage = "sha256:" + strings.Repeat("b", 64) + } + _ = json.NewEncoder(w).Encode(map[string]any{"Id": "runtime-id", "Image": actualImage, "Config": created.Config, "HostConfig": host, "State": map[string]any{"Status": "created"}}) + case strings.HasPrefix(path, "/images/"): + _ = json.NewEncoder(w).Encode(map[string]string{"Id": imageID}) + case path == "/volumes/create": + var value map[string]any + _ = json.NewDecoder(r.Body).Decode(&value) + volumes[value["Name"].(string)] = value + _ = json.NewEncoder(w).Encode(value) + case strings.HasPrefix(path, "/volumes/") && volumes[strings.TrimPrefix(path, "/volumes/")] != nil: + name := strings.TrimPrefix(path, "/volumes/") + if r.Method == http.MethodDelete { + delete(volumes, name) + w.WriteHeader(204) + } else { + _ = json.NewEncoder(w).Encode(volumes[name]) + } + case r.Method == http.MethodDelete && strings.HasPrefix(path, "/containers/"): + present = false + w.WriteHeader(204) + case r.Method == http.MethodGet: + w.WriteHeader(404) + _, _ = io.WriteString(w, `{"message":"not found"}`) + default: + t.Errorf("unexpected bootstrap or mutation: %s %s", r.Method, path) + w.WriteHeader(500) + } + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + resources := sandbox.Resources{CPUs: 3, MemoryMiB: 4096} + p, err := New(c, Config{InstallationID: uuid.NewString(), Image: imageID, Network: "bridge", Seccomp: `{}`, Resources: &resources}) + if err != nil { + t.Fatal(err) + } + resources.CPUs = 9 + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "secret", NetworkAccess: "enabled"} + info, err := p.Create(t.Context(), b) + if !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("drift accepted", err) + } + if info.Reference != b.Reference || !info.CreateSettled || info.BootstrapComplete || info.ProviderID == "" || info.State == "absent" { + t.Fatal("pre-bootstrap rejection lost its creation settlement", info) + } + if _, err = p.GetInfo(t.Context(), b.Reference); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("drift readiness accepted", err) + } + if err = p.Kill(t.Context(), b.Reference); err != nil { + t.Fatal("drift prevented cleanup", err) + } + if present || len(volumes) != 0 { + t.Fatal("cleanup retained resources") + } + }) + } +} + +func TestNilManagedLimitsKeepCallerManagedDefaults(t *testing.T) { + options := runtimeContainerOptions(Config{}, "fixture", nil, nil) + if options.HostConfig.NanoCPUs != 2e9 || options.HostConfig.Memory != 2*1024*1024*1024 { + t.Fatal("caller-managed defaults changed") + } +} diff --git a/services/core/internal/sandbox/docker/operations.go b/services/core/internal/sandbox/docker/operations.go new file mode 100644 index 000000000..6135f7829 --- /dev/null +++ b/services/core/internal/sandbox/docker/operations.go @@ -0,0 +1,74 @@ +package docker + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var _ sandbox.CheckpointProvider = (*Provider)(nil) +var _ sandbox.SelectionDiscoverer = (*Provider)(nil) +var _ sandbox.CredentialVerifier = (*Provider)(nil) +var _ runtimeobs.BatchSource = (*Provider)(nil) + +// Operations is this adapter's complete authored resource contract. +func Operations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "Suspend": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "Resume": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "Observe": {State: providercontract.Supported}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "docker_does_not_support_batch_observation"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "docker_does_not_support_selection_discovery"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "docker_does_not_support_credential_verification"}, + } +} +func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } +func (p *Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: Operations()["Initial"].Reason} +} +func (p *Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: Operations()["NewCompute"].Reason} +} +func (p *Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: Operations()["GetCompute"].Reason} +} +func (p *Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: Operations()["Suspend"].Reason} +} +func (p *Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: Operations()["Resume"].Reason} +} +func (p *Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: Operations()["KillCompute"].Reason} +} +func (p *Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: Operations()["DeleteSnapshot"].Reason} +} +func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: Operations()["RunCommandCompute"].Reason} +} +func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} +} +func (p *Provider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: Operations()["ObserveBatch"].Reason} +} +func (p *Provider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { + return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: Operations()["DiscoverSelection"].Reason} +} +func (p *Provider) VerifyCredential(context.Context, []sandbox.Reference) error { + return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: Operations()["VerifyCredential"].Reason} +} diff --git a/services/core/internal/sandbox/docker/provider.go b/services/core/internal/sandbox/docker/provider.go new file mode 100644 index 000000000..474c0d59d --- /dev/null +++ b/services/core/internal/sandbox/docker/provider.go @@ -0,0 +1,237 @@ +// Package docker is a thin SDK adapter for the dedicated, colocated Runtime. +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +const labelPrefix = "io.oac." + +// Config is trusted operator configuration, never public Session input. The +// immutable image contains the qualified native profile and all Runtime binaries. +// Seccomp is JSON content, not a path on the Docker host. Network must provide +// trusted daemon/model connectivity; native tool network policy is in the image. +type Config struct { + InstallationID, Image, Network, Seccomp string + ExtraHosts []string + NestedSandbox bool + Resources *sandbox.Resources +} +type Provider struct { + client *client.Client + config Config +} + +var _ sandbox.SandboxProvider = (*Provider)(nil) + +func New(c *client.Client, config Config) (*Provider, error) { + if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { + return nil, sandbox.ErrInvalid + } + if config.Resources != nil { + if ValidateResources(*config.Resources) != nil { + return nil, sandbox.ErrInvalid + } + resources := *config.Resources + config.Resources = &resources + } + return &Provider{client: c, config: config}, nil +} +func validID(v string) bool { + u, e := uuid.Parse(v) + return e == nil && u != uuid.Nil && u.String() == v +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func (p *Provider) name(r sandbox.Reference) string { + h := sha256.Sum256([]byte(p.config.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) + return "oac-runtime-" + hex.EncodeToString(h[:16]) +} +func (p *Provider) labels(r sandbox.Reference) map[string]string { + return map[string]string{ + labelPrefix + "installation": p.config.InstallationID, labelPrefix + "tenant": r.TenantID, labelPrefix + "environment": r.EnvironmentID, labelPrefix + "allocation": r.AllocationID, + } +} +func (p *Provider) owns(labels map[string]string, r sandbox.Reference) bool { + for k, v := range p.labels(r) { + if labels[k] != v { + return false + } + } + return true +} +func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (client.ContainerInspectResult, error) { + if !validReference(r) { + return client.ContainerInspectResult{}, sandbox.ErrInvalid + } + v, e := p.client.ContainerInspect(ctx, p.name(r), client.ContainerInspectOptions{}) + if errdefs.IsNotFound(e) { + return v, sandbox.ErrNotFound + } + if e != nil { + return v, e + } + if v.Container.Config == nil || !p.owns(v.Container.Config.Labels, r) { + return v, sandbox.ErrOwnership + } + return v, nil +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + v, e := p.inspect(ctx, r) + if e != nil { + return info, e + } + if e = p.verifyConfiguration(ctx, v); e != nil { + return info, e + } + info.ProviderID = v.Container.ID + info.State = string(v.Container.State.Status) + info.BootstrapComplete = info.State == "running" + return info, nil +} + +// Renew observes Docker state. Docker has no lease; service-owned expiry must be +// managed durably by Core. Never synthesize an expiry or revive a stopped Runtime. +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} + +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info := sandbox.Info{Reference: b.Reference} + policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} + if policy.Validate() != nil || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { + return info, sandbox.ErrInvalid + } + if existing, e := p.GetInfo(ctx, b.Reference); e == nil { + return existing, sandbox.ErrExists + } else if !errors.Is(e, sandbox.ErrNotFound) { + return info, e + } + domains, err := json.Marshal(policy.Hosts()) + if err != nil { + return info, sandbox.ErrInvalid + } + name := p.name(b.Reference) + // Retained volumes without a container are partial or lost state, not an + // invitation to overwrite native history with a new bootstrap identity. + for _, suffix := range []string{"-home", "-environment"} { + v, err := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if err == nil { + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + return info, sandbox.ErrExists + } + if !errdefs.IsNotFound(err) { + return info, err + } + } + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: p.labels(b.Reference)}) + if e != nil { + return info, e + } + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + } + options := runtimeContainerOptions(p.config, name, p.labels(b.Reference), []string{"OAC_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "OAC_RUNTIME_SESSION_ID=" + b.SessionID, "OAC_RUNTIME_NETWORK_ACCESS=" + policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS=" + string(domains)}) + options.Config.Cmd = []string{"connect", "--profile", "default", "--bootstrap-file", "/home/runtime/runtime-bootstrap.json"} + v, e := p.client.ContainerCreate(ctx, options) + if errdefs.IsConflict(e) { + return info, sandbox.ErrExists + } + if e != nil { + return info, e + } + info.ProviderID = v.ID + if p.config.Resources != nil { + actual, err := p.inspect(ctx, b.Reference) + if err != nil { + info.CreateSettled = true + return info, err + } + if err = p.verifyConfiguration(ctx, actual); err != nil { + // Container creation completed and bootstrap has not started. No + // outstanding mutation can recreate resources after owned cleanup. + info.CreateSettled = true + return info, err + } + } + // Any failure returns the retained allocation reference. The caller must Kill + // it, including on a lost acknowledgement. Never erase uncertain owner state. + if e = p.bootstrap(ctx, v.ID, b); e != nil { + return info, fmt.Errorf("runtime bootstrap: %w", e) + } + if _, e = p.client.ContainerStart(ctx, v.ID, client.ContainerStartOptions{}); e != nil { + return info, e + } + return p.GetInfo(ctx, b.Reference) +} + +// Kill is idempotent only for absence, not for errors or foreign ownership. It +// checks all resources before removing any and confirms removal of named volumes. +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + if !validReference(r) { + return sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil && !errors.Is(e, sandbox.ErrNotFound) { + return e + } + present := e == nil + name := p.name(r) + volumes := []string{} + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if errdefs.IsNotFound(e) { + continue + } + if e != nil { + return e + } + if !p.owns(v.Volume.Labels, r) { + return sandbox.ErrOwnership + } + volumes = append(volumes, name+suffix) + } + if present { + if _, e = p.client.ContainerRemove(ctx, c.Container.ID, client.ContainerRemoveOptions{Force: true}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + for _, v := range volumes { + if _, e = p.client.VolumeRemove(ctx, v, client.VolumeRemoveOptions{}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + if _, e = p.inspect(ctx, r); !errors.Is(e, sandbox.ErrNotFound) { + if e == nil { + return errors.New("container removal unconfirmed") + } + return e + } + for _, v := range volumes { + if _, e = p.client.VolumeInspect(ctx, v, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + if e == nil { + return errors.New("volume removal unconfirmed") + } + return e + } + } + return nil +} diff --git a/services/core/internal/sandbox/docker/provider_test.go b/services/core/internal/sandbox/docker/provider_test.go new file mode 100644 index 000000000..a7f71289b --- /dev/null +++ b/services/core/internal/sandbox/docker/provider_test.go @@ -0,0 +1,229 @@ +package docker + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +func TestProviderRejectsUnsafeOperatorConfiguration(t *testing.T) { + c, e := client.New() + if e != nil { + t.Fatal(e) + } + defer c.Close() + base := Config{InstallationID: uuid.NewString(), Image: "test@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`} + for _, change := range []func(*Config){func(c *Config) { c.Image = "mutable:latest" }, func(c *Config) { c.InstallationID = "" }, func(c *Config) { c.Network = "host" }, func(c *Config) { c.Network = "container:other" }, func(c *Config) { c.Seccomp = "" }} { + v := base + change(&v) + if _, e := New(c, v); !errors.Is(e, sandbox.ErrInvalid) { + t.Fatalf("accepted invalid configuration: %v", e) + } + } +} + +func TestBootstrapRequiresCompleteNetworkPolicyBeforeDockerEffects(t *testing.T) { + p := &Provider{} + for _, policy := range []sandbox.Bootstrap{ + {}, {NetworkAccess: "restricted"}, + {NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, + {NetworkAccess: "enabled", AllowedDomains: []string{"example.com"}}, + } { + policy.Reference = sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} + policy.SessionID, policy.DeviceID = uuid.NewString(), uuid.NewString() + policy.CoreURL, policy.Credential = "http://core.invalid/api/v1", "synthetic" + if _, err := p.Create(t.Context(), policy); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("invalid bootstrap reached Docker", err) + } + } +} + +// This optional Docker mechanism test uses a pinned fixture image whose entrypoint +// is sleep. It is not native/model acceptance; the real Runtime has separate checks. +func TestDockerProviderLifecycle(t *testing.T) { + image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") + if image == "" { + t.Skip("explicit Docker fixture image required") + } + seccomp, e := os.ReadFile("../../../deploy/codex/seccomp.json") + if e != nil { + t.Fatal(e) + } + c, e := client.New(client.FromEnv) + if e != nil { + t.Fatal(e) + } + defer c.Close() + installationID := uuid.NewString() + p, e := New(c, Config{InstallationID: installationID, Image: image, Network: "bridge", Seccomp: string(seccomp)}) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + bootstrap := func() sandbox.Bootstrap { + return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential", NetworkAccess: "enabled"} + } + b := bootstrap() + b.NetworkAccess, b.AllowedDomains = "restricted", []string{"Example.com", "api.example.com"} + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if e := p.Kill(ctx, b.Reference); e != nil { + t.Error(e) + } + }) + t.Run("stdin concurrent output and EOF", func(t *testing.T) { + inputOwner := bootstrap() + if _, err := p.Create(ctx, inputOwner); err != nil { + t.Fatal(err) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + if err := p.Kill(cleanup, inputOwner.Reference); err != nil { + t.Error(err) + } + }() + for _, data := range [][]byte{{}, bytes.Repeat([]byte{0, 255, 10, 1, 42}, 900000)} { + result, err := p.RunCommand(ctx, inputOwner.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "head -c 131072 /dev/zero; sha256sum"}, Stdin: data}) + digest := sha256.Sum256(data) + if err != nil || result.ExitCode != 0 || !strings.HasSuffix(result.Stdout, hex.EncodeToString(digest[:])+" -\n") || len(result.Stdout) != 131072+68 { + t.Fatalf("stdin/EOF failure: input=%d stdout=%d exit=%d error=%v", len(data), len(result.Stdout), result.ExitCode, err) + } + } + }) + info, e := p.Create(ctx, b) + contracttest.AssertObservation(t, info, e, b.Reference, "", "running") + resources, e := p.Observe(ctx, runtimeobs.Target{ + TenantID: b.TenantID, SessionID: b.SessionID, EnvironmentID: b.EnvironmentID, Mode: runtimeobs.ModeManaged, + Instance: runtimeobs.Instance{AllocationID: b.AllocationID, ProviderKey: installationID, DeviceID: b.DeviceID}, + }) + if e != nil || resources.StartedAt == nil || resources.CPUUsageSecondsTotal == nil || resources.MemoryUsageBytes == nil || resources.CPUCapacityCores == nil || resources.MemoryLimitBytes == nil { + t.Fatalf("bad resource observation: %+v %v", resources, e) + } + inspected, e := p.inspect(ctx, b.Reference) + if e != nil { + t.Fatal(e) + } + if strings.Contains(string(inspected.Raw), b.Credential) || inspected.Container.Config.User != "1000:1000" || !inspected.Container.HostConfig.ReadonlyRootfs || inspected.Container.HostConfig.Privileged { + t.Fatal("unsafe Docker configuration") + } + for _, value := range []string{"OAC_RUNTIME_NETWORK_ACCESS=restricted", `OAC_RUNTIME_ALLOWED_DOMAINS=["api.example.com","example.com"]`} { + found := false + for _, entry := range inspected.Container.Config.Env { + found = found || entry == value + } + if !found { + t.Fatalf("bootstrap lost network policy: %s", value) + } + } + changed := b + changed.Credential = "must-not-replace-existing" + if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("duplicate not rejected: %v", e) + } + r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/runtime-bootstrap.json"}}) + if e != nil { + t.Fatal(e) + } + auth, decodeErr := runtimebootstrap.Decode([]byte(r.Stdout)) + if decodeErr != nil || auth.Credential != b.Credential || auth.DeviceID != b.DeviceID { + t.Fatal("bootstrap changed or malformed") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "printf retained > /environment/workspace/history; printf failed >&2; exit 7"}}) + if e != nil || r.ExitCode != 7 || r.Stderr != "failed" { + t.Fatalf("lost command status: %+v %v", r, e) + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "set -eu; test \"$(cat /workspace/history)\" = retained; printf replaced > /environment/staging/replacement; mv /environment/staging/replacement /environment/workspace/history; cat /workspace/history"}}) + if e != nil || r.ExitCode != 0 || r.Stdout != "replaced" { + t.Fatal("public workspace view or atomic staging failed", e) + } + wrong := b.Reference + wrong.TenantID = uuid.NewString() + if _, e = p.GetInfo(ctx, wrong); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("foreign allocation visible") + } + if e = p.Kill(ctx, wrong); e != nil { + t.Fatal(e) + } + if _, e = p.Renew(ctx, b.Reference); e != nil { + t.Fatal("wrong tenant removed the owner") + } + timeout := 1 + if _, e = c.ContainerRestart(ctx, info.ProviderID, client.ContainerRestartOptions{Timeout: &timeout}); e != nil { + t.Fatal(e) + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/environment/workspace/history"}}) + if e != nil || r.Stdout != "replaced" { + t.Fatal("restart lost workspace") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "touch /cannot-write-root"}}) + if e != nil || r.ExitCode == 0 { + t.Fatal("root filesystem writable") + } + // Container loss must not trigger credential overwrite or state replacement. + if _, e = c.ContainerRemove(ctx, info.ProviderID, client.ContainerRemoveOptions{Force: true}); e != nil { + t.Fatal(e) + } + if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("retained volumes reused: %v", e) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e = c.VolumeInspect(ctx, p.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + t.Fatal("named volume remains") + } + } + // A colliding resource with different ownership cannot be deleted, including + // when its container is absent after a partial creation. + foreign := bootstrap() + volume := p.name(foreign.Reference) + "-home" + if _, e = c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: volume, Labels: map[string]string{"fixture": "foreign"}}); e != nil { + t.Fatal(e) + } + t.Cleanup(func() { + _, e := c.VolumeRemove(context.Background(), volume, client.VolumeRemoveOptions{}) + if e != nil { + t.Error(e) + } + }) + if e = p.Kill(ctx, foreign.Reference); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume cleanup accepted") + } + if _, e = p.Create(ctx, foreign); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume bootstrap accepted") + } + // Closing initialization output is not process termination. Require explicit + // reclamation, without returning partial output as a successful command. + next := bootstrap() + defer p.Kill(context.Background(), next.Reference) + if _, e = p.Create(ctx, next); e != nil { + t.Fatal(e) + } + short, stop := context.WithTimeout(ctx, 100*time.Millisecond) + _, e = p.RunCommand(short, next.Reference, sandbox.Command{Args: []string{"sleep", "30"}}) + stop() + if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { + t.Fatalf("timeout classified as certain: %v", e) + } + if e = p.Kill(ctx, next.Reference); e != nil { + t.Fatal(e) + } +} diff --git a/services/core/internal/sandbox/docker/recovery_test.go b/services/core/internal/sandbox/docker/recovery_test.go new file mode 100644 index 000000000..06ce2e8ae --- /dev/null +++ b/services/core/internal/sandbox/docker/recovery_test.go @@ -0,0 +1,138 @@ +package docker + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// These controlled transport faults use real Docker compute and volumes. They +// establish Provider recovery observations, not native or model acceptance. +func TestDockerProviderRecoveryObservations(t *testing.T) { + image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") + if image == "" { + t.Skip("explicit Docker fixture image required") + } + seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") + if err != nil { + t.Fatal(err) + } + for _, fault := range []string{"lost-start-response", "partial-volumes"} { + t.Run(fault, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }} + defer transport.CloseIdleConnections() + var fired atomic.Bool + var creates atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create") { + creates.Add(1) + if fault == "partial-volumes" { + fired.Store(true) + http.Error(w, `{"message":"injected before container create"}`, 500) + return + } + } + request := r.Clone(r.Context()) + request.RequestURI = "" + request.URL.Scheme = "http" + request.URL.Host = "docker" + request.Host = "docker" + response, e := transport.RoundTrip(request) + if e != nil { + http.Error(w, "Docker transport failed", 502) + return + } + defer response.Body.Close() + if fault == "lost-start-response" && r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start") && response.StatusCode == 204 && fired.CompareAndSwap(false, true) { + connection, _, e := w.(http.Hijacker).Hijack() + if e == nil { + _ = connection.Close() + } + return + } + for k, values := range response.Header { + for _, v := range values { + w.Header().Add(k, v) + } + } + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, response.Body) + })) + defer proxy.Close() + c, e := client.New(client.WithHost(proxy.URL)) + if e != nil { + t.Fatal(e) + } + defer c.Close() + config := Config{InstallationID: uuid.NewString(), Image: image, Network: "bridge", Seccomp: string(seccomp)} + p, e := New(c, config) + if e != nil { + t.Fatal(e) + } + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-recovery-token", NetworkAccess: "enabled"} + direct, e := client.New(client.WithHost("unix:///var/run/docker.sock")) + if e != nil { + t.Fatal(e) + } + defer direct.Close() + recovered, e := New(direct, config) + if e != nil { + t.Fatal(e) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + if e := recovered.Kill(cleanup, b.Reference); e != nil { + t.Error(e) + } + }() + if _, e := p.Create(ctx, b); e == nil || !fired.Load() { + t.Fatal("Create did not expose the injected uncertainty") + } + info, e := recovered.GetInfo(ctx, b.Reference) + if fault == "lost-start-response" { + if e != nil || info.State != "running" || !info.BootstrapComplete { + t.Fatalf("original allocation not recoverable: %+v %v", info, e) + } + } else { + if !errors.Is(e, sandbox.ErrNotFound) { + t.Fatalf("partial allocation observation: %v", e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); e != nil { + t.Fatal("missing container concealed missing volume fixture", e) + } + } + } + if creates.Load() != 1 { + t.Fatalf("Create was replayed %d times", creates.Load()) + } + if e := recovered.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e := direct.VolumeInspect(ctx, recovered.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + t.Fatal("owned volume remains", e) + } + } + }) + } +} diff --git a/services/core/internal/sandbox/docker/resources.go b/services/core/internal/sandbox/docker/resources.go new file mode 100644 index 000000000..387214dcb --- /dev/null +++ b/services/core/internal/sandbox/docker/resources.go @@ -0,0 +1,93 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +var _ runtimeobs.Source = (*Provider)(nil) + +func (*Provider) ObservationProviderType() string { return "docker" } + +// Observe is read-only. Inspect verifies allocation ownership before Docker +// statistics are requested; it never renews or changes the container. +func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { + if target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID == "" { + return runtimeobs.Sample{}, sandbox.ErrInvalid + } + if target.Instance.ProviderKey != p.config.InstallationID { + return runtimeobs.Sample{}, sandbox.ErrOwnership + } + reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} + inspected, err := p.inspect(ctx, reference) + if errors.Is(err, sandbox.ErrNotFound) { + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + } + if err != nil { + return runtimeobs.Sample{}, err + } + if inspected.Container.State == nil || !inspected.Container.State.Running { + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + } + result, err := p.client.ContainerStats(ctx, inspected.Container.ID, client.ContainerStatsOptions{Stream: false, IncludePreviousSample: false}) + if err != nil { + return runtimeobs.Sample{}, fmt.Errorf("read Docker Runtime statistics: %w", err) + } + defer result.Body.Close() + var stats dockerStatsResponse + if err := json.NewDecoder(result.Body).Decode(&stats); err != nil { + return runtimeobs.Sample{}, fmt.Errorf("decode Docker Runtime statistics: %w", err) + } + return sampleFromDocker(inspected.Container, stats) +} + +type dockerStatsResponse struct { + Read time.Time `json:"read"` + CPUStats *struct { + CPUUsage *struct { + TotalUsage *uint64 `json:"total_usage"` + } `json:"cpu_usage"` + } `json:"cpu_stats"` + MemoryStats *struct { + Usage *uint64 `json:"usage"` + } `json:"memory_stats"` +} + +func sampleFromDocker(inspected container.InspectResponse, stats dockerStatsResponse) (runtimeobs.Sample, error) { + if inspected.State == nil || inspected.HostConfig == nil || !inspected.State.Running || stats.Read.IsZero() { + return runtimeobs.Sample{}, errors.New("incomplete Docker Runtime observation") + } + startedAt, err := time.Parse(time.RFC3339Nano, inspected.State.StartedAt) + if err != nil || startedAt.IsZero() || startedAt.After(stats.Read) { + return runtimeobs.Sample{}, errors.New("invalid Docker Runtime start time") + } + sample := runtimeobs.Sample{ + ObservedAt: stats.Read, + StartedAt: &startedAt, + } + if stats.CPUStats != nil && stats.CPUStats.CPUUsage != nil && stats.CPUStats.CPUUsage.TotalUsage != nil { + cpuUsage := float64(*stats.CPUStats.CPUUsage.TotalUsage) / float64(time.Second) + sample.CPUUsageSecondsTotal = &cpuUsage + } + if stats.MemoryStats != nil && stats.MemoryStats.Usage != nil { + memoryUsage := *stats.MemoryStats.Usage + sample.MemoryUsageBytes = &memoryUsage + } + if inspected.HostConfig.NanoCPUs > 0 { + capacity := float64(inspected.HostConfig.NanoCPUs) / 1_000_000_000 + sample.CPUCapacityCores = &capacity + } + if inspected.HostConfig.Memory > 0 { + limit := uint64(inspected.HostConfig.Memory) + sample.MemoryLimitBytes = &limit + } + return sample, nil +} diff --git a/services/core/internal/sandbox/docker/resources_test.go b/services/core/internal/sandbox/docker/resources_test.go new file mode 100644 index 000000000..d31d14f4c --- /dev/null +++ b/services/core/internal/sandbox/docker/resources_test.go @@ -0,0 +1,166 @@ +package docker + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/google/uuid" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +func TestObserveVerifiesOwnershipThenReadsOneShotStats(t *testing.T) { + installationID := uuid.NewString() + target := runtimeobs.Target{ + TenantID: uuid.NewString(), SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), Mode: runtimeobs.ModeManaged, + Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), ProviderKey: installationID, DeviceID: uuid.NewString()}, + } + observed := time.Now().UTC().Truncate(time.Microsecond) + started := observed.Add(-time.Minute) + statsRead := false + omitMeasurements := false + running := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/json"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "Id": "container-id", + "State": map[string]any{"Status": "running", "Running": running, "StartedAt": started.Format(time.RFC3339Nano)}, + "HostConfig": map[string]any{"NanoCpus": 2_000_000_000, "Memory": 2048}, + "Config": map[string]any{"Labels": map[string]string{ + labelPrefix + "installation": installationID, + labelPrefix + "tenant": target.TenantID, + labelPrefix + "environment": target.EnvironmentID, + labelPrefix + "allocation": target.Instance.AllocationID, + }}, + }) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/containers/container-id/stats"): + if r.URL.Query().Get("stream") != "false" || r.URL.Query().Get("one-shot") != "true" { + t.Errorf("stats request was not one-shot: %s", r.URL.RawQuery) + } + statsRead = true + if omitMeasurements { + _ = json.NewEncoder(w).Encode(map[string]any{"read": observed}) + return + } + _ = json.NewEncoder(w).Encode(container.StatsResponse{ + Read: observed, + CPUStats: container.CPUStats{CPUUsage: container.CPUUsage{TotalUsage: 1_500_000_000}}, + MemoryStats: container.MemoryStats{Usage: 1024}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + p, err := New(c, Config{InstallationID: installationID, Image: "fixture@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`}) + if err != nil { + t.Fatal(err) + } + sample, err := p.Observe(t.Context(), target) + if err != nil || !statsRead || sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 1.5 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 1024 { + t.Fatalf("bad one-shot observation: %+v %v stats=%v", sample, err, statsRead) + } + omitMeasurements = true + missing, err := p.Observe(t.Context(), target) + if err != nil || missing.CPUUsageSecondsTotal != nil || missing.MemoryUsageBytes != nil || missing.CPUCapacityCores == nil || missing.MemoryLimitBytes == nil { + t.Fatalf("missing Docker measurements became zero: %+v %v", missing, err) + } + running = false + statsRead = false + if _, err := p.Observe(t.Context(), target); !errors.Is(err, runtimeobs.ErrNotRunning) || statsRead { + t.Fatalf("stopped Runtime was not classified before stats: %v stats=%v", err, statsRead) + } + running = true + foreign := target + foreign.Instance.ProviderKey = uuid.NewString() + statsRead = false + if _, err := p.Observe(t.Context(), foreign); err == nil || statsRead { + t.Fatal("foreign provider identity reached Docker stats") + } +} + +func TestSampleFromDockerPreservesObservedZeroAndConfiguredCapacity(t *testing.T) { + observed := time.Date(2026, 9, 22, 1, 2, 3, 0, time.UTC) + started := observed.Add(-5 * time.Minute) + zero := uint64(0) + sample, err := sampleFromDocker(container.InspectResponse{ + State: &container.State{Running: true, StartedAt: started.Format(time.RFC3339Nano)}, + HostConfig: &container.HostConfig{Resources: container.Resources{NanoCPUs: 2_000_000_000, Memory: 2 * 1024 * 1024 * 1024}}, + }, testDockerStats(observed, &zero, &zero)) + if err != nil { + t.Fatal(err) + } + if sample.ObservedAt != observed || sample.StartedAt == nil || !sample.StartedAt.Equal(started) { + t.Fatalf("lost Docker observation time: %+v", sample) + } + if sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 0 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 0 { + t.Fatalf("observed zero became unavailable: %+v", sample) + } + if sample.CPUCapacityCores == nil || *sample.CPUCapacityCores != 2 || sample.MemoryLimitBytes == nil || *sample.MemoryLimitBytes != 2*1024*1024*1024 { + t.Fatalf("lost configured capacity: %+v", sample) + } +} + +func TestSampleFromDockerNormalizesCumulativeCPU(t *testing.T) { + observed := time.Now().UTC() + started := observed.Add(-time.Hour) + cpu, memory := uint64(2_500_000_000), uint64(4096) + sample, err := sampleFromDocker(container.InspectResponse{ + State: &container.State{Running: true, StartedAt: started.Format(time.RFC3339Nano)}, + HostConfig: &container.HostConfig{}, + }, testDockerStats(observed, &cpu, &memory)) + if err != nil { + t.Fatal(err) + } + if sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 2.5 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 4096 { + t.Fatalf("bad Docker normalization: %+v", sample) + } + if sample.CPUCapacityCores != nil || sample.MemoryLimitBytes != nil { + t.Fatalf("invented unconfigured capacity: %+v", sample) + } +} + +func TestSampleFromDockerRejectsIncompleteState(t *testing.T) { + observed := time.Now().UTC() + for _, inspected := range []container.InspectResponse{ + {}, + {State: &container.State{Running: false}, HostConfig: &container.HostConfig{}}, + {State: &container.State{Running: true, StartedAt: "invalid"}, HostConfig: &container.HostConfig{}}, + } { + if _, err := sampleFromDocker(inspected, dockerStatsResponse{Read: observed}); err == nil { + t.Fatal("accepted incomplete Docker state") + } + } +} + +func testDockerStats(observed time.Time, cpu, memory *uint64) dockerStatsResponse { + stats := dockerStatsResponse{Read: observed} + if cpu != nil { + stats.CPUStats = &struct { + CPUUsage *struct { + TotalUsage *uint64 `json:"total_usage"` + } `json:"cpu_usage"` + }{CPUUsage: &struct { + TotalUsage *uint64 `json:"total_usage"` + }{TotalUsage: cpu}} + } + if memory != nil { + stats.MemoryStats = &struct { + Usage *uint64 `json:"usage"` + }{Usage: memory} + } + return stats +} diff --git a/services/agents-api/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go similarity index 81% rename from services/agents-api/internal/sandbox/docker/selection.go rename to services/core/internal/sandbox/docker/selection.go index 53529093d..426cf6005 100644 --- a/services/agents-api/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -1,7 +1,7 @@ package docker import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Runtime: true} } diff --git a/services/core/internal/sandbox/e2b/contract_test.go b/services/core/internal/sandbox/e2b/contract_test.go new file mode 100644 index 000000000..b4d64227b --- /dev/null +++ b/services/core/internal/sandbox/e2b/contract_test.go @@ -0,0 +1,57 @@ +package e2b + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest" + "github.com/google/uuid" + "testing" +) + +type contractCaller func(context.Context, Request) (Response, error) + +func (f contractCaller) Call(ctx context.Context, q Request) (Response, error) { return f(ctx, q) } + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + p, _, r := fixture(t) + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p.caller = contractCaller(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, Info: &sandbox.Info{Reference: foreign, ProviderID: "foreign", State: "running", CreateSettled: true, BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + }) + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{s.Operation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + p, f, r := fixture(t) + f.response.Info = &sandbox.Info{Reference: r, ProviderID: "native-owned", State: "running", CreateSettled: true, BootstrapComplete: true} + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(bounded(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.Renew(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") +} diff --git a/services/agents-api/internal/sandbox/e2b/credential.go b/services/core/internal/sandbox/e2b/credential.go similarity index 95% rename from services/agents-api/internal/sandbox/e2b/credential.go rename to services/core/internal/sandbox/e2b/credential.go index f7d795c6e..52ed2ae7e 100644 --- a/services/agents-api/internal/sandbox/e2b/credential.go +++ b/services/core/internal/sandbox/e2b/credential.go @@ -3,7 +3,7 @@ package e2b import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "time" ) diff --git a/services/core/internal/sandbox/e2b/deployment.go b/services/core/internal/sandbox/e2b/deployment.go new file mode 100644 index 000000000..42c0f5798 --- /dev/null +++ b/services/core/internal/sandbox/e2b/deployment.go @@ -0,0 +1,137 @@ +package e2b + +import ( + "context" + "errors" + "math" + "strings" + "unicode" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func Policy() sandbox.DeploymentPolicy { + return sandbox.DeploymentPolicy{RuntimeError: "E2B Runtime is selected by its immutable template build"} +} + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } + +func ValidateConfiguration(c *sandbox.E2BConfiguration) error { + if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { + return sandbox.ErrInvalid + } + if _, _, err := NormalizeEndpoint(c.APIURL, c.Domain); err != nil { + return sandbox.ErrInvalid + } + template, build, ok := strings.Cut(c.Template, ":") + id, err := uuid.Parse(build) + if !ok || template == "" || len(template) > 128 || err != nil || id == uuid.Nil || id.String() != build { + return sandbox.ErrInvalid + } + for _, c := range template { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { + return sandbox.ErrInvalid + } + } + return nil +} + +// NormalizeSelection accepts omitted candidate resources only until live build +// discovery. Persistence requires ValidateSpecification after preparation. +func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { + if s.Resources == (sandbox.Resources{}) { + if s.Runtime != nil { + return s, &sandbox.ValidationError{Param: "runtime", Message: sandbox.ErrInvalid.Error() + ": " + Policy().RuntimeError} + } + } else if err := ValidateSpecification(s.DeploymentSpec); err != nil { + return s, err + } + if err := ValidateConfiguration(s.E2B); err != nil { + return s, err + } + c := *s.E2B + c.APIURL, c.Domain, _ = NormalizeEndpoint(c.APIURL, c.Domain) + s.E2B = &c + return s, nil +} + +func WithTemplateBuild(input sandbox.Selection, build *sandbox.TemplateBuild) sandbox.Selection { + if input.E2B != nil && build != nil { + c, b := *input.E2B, *build + if input.Resources == (sandbox.Resources{}) { + input.Resources = sandbox.Resources{CPUs: uint32(b.CPUs), MemoryMiB: uint32(b.MemoryMiB)} + } + c.TemplateBuild = &b + input.E2B = &c + } + return input +} + +// DiscoverSelection checks the immutable native build without allocating compute. +func (p *Provider) DiscoverSelection(ctx context.Context, s sandbox.Selection) (sandbox.Selection, error) { + build, err := p.ValidateDeployment(ctx) + if err != nil { + if errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) { + return s, err + } + if errors.Is(err, sandbox.ErrInvalid) { + return s, ErrTemplateInvalid + } + return s, ErrRequestUnconfirmed + } + recorded := &sandbox.TemplateBuild{Status: build.Status, CPUs: int32(build.CPUs), MemoryMiB: int32(build.MemoryMiB)} + if build.RootDiskMiB != nil && *build.RootDiskMiB <= math.MaxInt32 { + disk := int32(*build.RootDiskMiB) + recorded.RootDiskMiB = &disk + } + s = WithTemplateBuild(s, recorded) + if err := ValidateSpecification(s.DeploymentSpec); err != nil { + return s, &sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"} + } + return s, nil +} + +// RestoreSelection normalizes stored connection fields without admitting a new +// template or requiring remote availability for retained-resource cleanup. +func RestoreSelection(s sandbox.Selection) (sandbox.Selection, error) { + if s.E2B == nil { + return s, sandbox.ErrInvalid + } + c := *s.E2B + var err error + c.APIURL, c.Domain, err = NormalizeEndpoint(c.APIURL, c.Domain) + s.E2B = &c + return s, err +} +func ResolveChange(next, previous sandbox.Selection) sandbox.Selection { + if next.E2B == nil || previous.E2B == nil { + return next + } + c := *next.E2B + c.ReplaceCredential = c.ReplaceCredential || c.APIKey != "" + if c.APIURL == "" && c.Domain == "" { + c.APIURL, c.Domain = previous.E2B.APIURL, previous.E2B.Domain + } + if c.APIKey == "" && !c.ReplaceCredential { + c.APIKey = previous.E2B.APIKey + } + if c.Template == previous.E2B.Template && next.Resources == (sandbox.Resources{}) { + next.Resources = previous.Resources + } + next.E2B = &c + return next +} + +func ReplaceCredential(owner, candidate sandbox.Selection) sandbox.Selection { + if owner.E2B != nil && candidate.E2B != nil { + c := *owner.E2B + c.APIKey = candidate.E2B.APIKey + owner.E2B = &c + } + return owner +} +func CredentialRequiresReset(err error) bool { + return errors.Is(err, ErrCredentialInvalid) || errors.Is(err, ErrTeamMismatch) +} diff --git a/services/core/internal/sandbox/e2b/deployment_test.go b/services/core/internal/sandbox/e2b/deployment_test.go new file mode 100644 index 000000000..95864143a --- /dev/null +++ b/services/core/internal/sandbox/e2b/deployment_test.go @@ -0,0 +1,47 @@ +package e2b + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestDeploymentValidationIsBoundedAndNeedsExplicitProof(t *testing.T) { + p, caller, _ := fixture(t) + resources := sandbox.Resources{CPUs: 2, MemoryMiB: 2048} + config := p.config + config.Resources = &resources + p, err := NewWithCaller(config, caller) + if err != nil { + t.Fatal(err) + } + resources.CPUs = 9 + disk := uint32(24063) + caller.response.DeploymentValid = true + caller.response.TemplateBuild = &TemplateBuild{Status: "ready", CPUs: 2, MemoryMiB: 2048, RootDiskMiB: &disk} + build, err := p.ValidateDeployment(context.Background()) + if err != nil || build.CPUs != 2 || build.MemoryMiB != 2048 || build.RootDiskMiB == nil || *build.RootDiskMiB != disk || build.Status != "ready" { + t.Fatalf("validated build = %+v %v", build, err) + } + q := caller.requests[0] + if q.Operation != "validate_deployment" || q.Reference != (sandbox.Reference{}) || q.Bootstrap != nil || q.Config.Resources.CPUs != 2 || time.Until(q.Deadline) > 30*time.Second || time.Until(q.Deadline) <= 0 { + t.Fatalf("invalid validation request %+v", q) + } + for _, response := range []Response{ + {Version: ProtocolVersion, TemplateBuild: caller.response.TemplateBuild}, + {Version: ProtocolVersion, DeploymentValid: true}, + {Version: ProtocolVersion, DeploymentValid: true, TemplateBuild: &TemplateBuild{Status: "ready", CPUs: 4, MemoryMiB: 2048}}, + } { + caller.response = response + if _, err = p.ValidateDeployment(t.Context()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatal("missing proof accepted", err) + } + } + config.Resources = &sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192} + if _, err = NewWithCaller(config, caller); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("unsupported disk accepted", err) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/discovery_test.go b/services/core/internal/sandbox/e2b/discovery_test.go similarity index 95% rename from services/agents-api/internal/sandbox/e2b/discovery_test.go rename to services/core/internal/sandbox/e2b/discovery_test.go index fd9474cde..d0d0e991a 100644 --- a/services/agents-api/internal/sandbox/e2b/discovery_test.go +++ b/services/core/internal/sandbox/e2b/discovery_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/sandbox/e2b/endpoint.go b/services/core/internal/sandbox/e2b/endpoint.go similarity index 100% rename from services/agents-api/internal/sandbox/e2b/endpoint.go rename to services/core/internal/sandbox/e2b/endpoint.go diff --git a/services/agents-api/internal/sandbox/e2b/endpoint_test.go b/services/core/internal/sandbox/e2b/endpoint_test.go similarity index 100% rename from services/agents-api/internal/sandbox/e2b/endpoint_test.go rename to services/core/internal/sandbox/e2b/endpoint_test.go diff --git a/services/core/internal/sandbox/e2b/observations.go b/services/core/internal/sandbox/e2b/observations.go new file mode 100644 index 000000000..0b5da3c5f --- /dev/null +++ b/services/core/internal/sandbox/e2b/observations.go @@ -0,0 +1,162 @@ +package e2b + +import ( + "context" + "math" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var ( + _ runtimeobs.Source = (*Provider)(nil) + _ runtimeobs.BatchSource = (*Provider)(nil) +) + +// maxClockLead tolerates an E2B metrics timestamp slightly ahead of Core's +// clock. A larger lead is treated as an unavailable sample, never as fresh data. +const maxClockLead = 30 * time.Second + +// Observation is one allocation's latest E2B metrics point. Status is +// observed, not_running, unavailable or ownership; only observed carries +// values. A malformed E2B point is unavailable for its row only. Values keep E2B's units: CPUUsedPct is a percentage of all +// CPUCount cores, and memory and disk are in bytes. +type Observation struct { + sandbox.Reference + Status string + ObservedAt, StartedAt *time.Time `json:",omitempty"` + CPUCount, CPUUsedPct *float64 `json:",omitempty"` + MemUsed, MemTotal *uint64 `json:",omitempty"` + DiskUsed, DiskTotal *uint64 `json:",omitempty"` +} + +func (*Provider) ObservationProviderType() string { return "e2b" } + +// Observe reads one allocation through the same helper request as ObserveBatch. +func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { + results, _ := p.ObserveBatch(ctx, []runtimeobs.Target{target}) + return results[0].Sample, results[0].Err +} + +// ObserveBatch reads up to runtimeobs.MaxBatchTargets allocations with one +// helper request. The helper takes sandbox IDs from its private receipts, +// confirms each running sandbox by its allocation labels and reads E2B's batch +// metrics once. It never connects to, renews or changes a sandbox. +func (p *Provider) ObserveBatch(ctx context.Context, targets []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + results := make([]runtimeobs.BatchResult, len(targets)) + references := make([]sandbox.Reference, 0, len(targets)) + positions := make([]int, 0, len(targets)) + for index, target := range targets { + reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} + switch { + case target.Mode != runtimeobs.ModeManaged || !validReference(reference) || len(targets) > runtimeobs.MaxBatchTargets: + results[index].Err = sandbox.ErrInvalid + case target.Instance.ProviderKey != p.config.InstallationID: + results[index].Err = sandbox.ErrOwnership + default: + references = append(references, reference) + positions = append(positions, index) + } + } + if len(references) == 0 { + return results, nil + } + observations, err := p.observe(ctx, references) + now := p.now() + for offset, index := range positions { + if err != nil { + results[index].Err = err + continue + } + results[index].Sample, results[index].Err = sampleFromObservation(observations[offset], now) + } + return results, nil +} + +func (p *Provider) observe(ctx context.Context, references []sandbox.Reference) ([]Observation, error) { + deadline, ok := ctx.Deadline() + if !ok { + return nil, sandbox.ErrInvalid + } + if err := ctx.Err(); err != nil { + return nil, err + } + out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: "observe", Config: p.config, References: references, Deadline: deadline}) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if err != nil || out.Version != ProtocolVersion || out.Info != nil || out.Command != nil || out.DeploymentValid || out.TemplateBuild != nil { + return nil, runtimeobs.ErrUnavailable + } + switch out.ErrorCode { + case "": + case "invalid": + return nil, sandbox.ErrInvalid + case "ownership": + return nil, sandbox.ErrOwnership + default: + // E2B API failures, including a rejected credential, leave rows unavailable. + return nil, runtimeobs.ErrUnavailable + } + if len(out.Observations) != len(references) { + return nil, sandbox.ErrInvalid + } + for index, observation := range out.Observations { + if observation.Reference != references[index] { + return nil, sandbox.ErrOwnership + } + } + return out.Observations, nil +} + +// sampleFromObservation maps E2B's latest point to the provider-neutral +// sample. E2B reports no cumulative CPU time, so usage seconds stay nil. +func sampleFromObservation(observation Observation, now time.Time) (runtimeobs.Sample, error) { + switch observation.Status { + case "observed": + case "not_running": + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + case "unavailable": + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + case "ownership": + return runtimeobs.Sample{}, sandbox.ErrOwnership + default: + // An unknown status breaks Core's own helper protocol. + return runtimeobs.Sample{}, sandbox.ErrInvalid + } + // Malformed provider data leaves this row unavailable, not the whole page. + if observation.ObservedAt == nil || observation.StartedAt == nil || observation.CPUCount == nil || observation.CPUUsedPct == nil || + observation.MemUsed == nil || observation.MemTotal == nil || !finite(*observation.CPUCount) || *observation.CPUCount <= 0 || + !finite(*observation.CPUUsedPct) || *observation.CPUUsedPct < 0 || *observation.MemTotal == 0 { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + // E2B timestamps use the provider's clock. Record a small lead at Core's + // time so that a fresh point is not rejected as a future sample. + observedAt, startedAt := *observation.ObservedAt, *observation.StartedAt + if lead := observedAt.Sub(now); lead > 0 { + if lead > maxClockLead { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + observedAt = now + } + if startedAt.After(observedAt) { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + ratio, capacity := *observation.CPUUsedPct/100, *observation.CPUCount + memoryUsage, memoryLimit := *observation.MemUsed, *observation.MemTotal + sample := runtimeobs.Sample{ + ObservedAt: observedAt, StartedAt: &startedAt, + CPUUtilizationRatio: &ratio, CPUCapacityCores: &capacity, + MemoryUsageBytes: &memoryUsage, MemoryLimitBytes: &memoryLimit, + } + // Templates with an envd older than E2B's disk metrics report no disk + // capacity; keep disk unknown rather than an observed zero. + if observation.DiskUsed != nil && observation.DiskTotal != nil && *observation.DiskTotal > 0 { + diskUsage, diskLimit := *observation.DiskUsed, *observation.DiskTotal + sample.DiskUsageBytes, sample.DiskLimitBytes = &diskUsage, &diskLimit + } + return sample, nil +} + +func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } diff --git a/services/core/internal/sandbox/e2b/observations_test.go b/services/core/internal/sandbox/e2b/observations_test.go new file mode 100644 index 000000000..c736e862c --- /dev/null +++ b/services/core/internal/sandbox/e2b/observations_test.go @@ -0,0 +1,61 @@ +package e2b + +import ( + "errors" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func TestObserveBatchMapsMetricsAndKeepsUnmeasuredValuesNull(t *testing.T) { + p, caller, running := fixture(t) + stopped := sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} + now := time.Date(2026, 9, 25, 10, 31, 37, 0, time.UTC) + p.now = func() time.Time { return now } + started, observed := now.Add(-3*time.Second), now.Add(time.Second) // E2B's clock leads by one second. + count, percent, memoryUsed, memoryTotal, diskUsed, diskTotal := 2.0, 19.55, uint64(183836672), uint64(2079141888), uint64(1593188352), uint64(23511863296) + caller.response.Observations = []Observation{ + {Reference: running, Status: "observed", ObservedAt: &observed, StartedAt: &started, CPUCount: &count, CPUUsedPct: &percent, + MemUsed: &memoryUsed, MemTotal: &memoryTotal, DiskUsed: &diskUsed, DiskTotal: &diskTotal}, + {Reference: stopped, Status: "not_running"}, + } + targets := []runtimeobs.Target{} + for _, reference := range []sandbox.Reference{running, stopped} { + targets = append(targets, runtimeobs.Target{TenantID: reference.TenantID, EnvironmentID: reference.EnvironmentID, Mode: runtimeobs.ModeManaged, + Instance: runtimeobs.Instance{AllocationID: reference.AllocationID, ProviderKey: p.config.InstallationID}}) + } + results, err := p.ObserveBatch(bounded(t), targets) + if err != nil || len(caller.requests) != 1 || caller.requests[0].Operation != "observe" || len(caller.requests[0].References) != 2 || + caller.requests[0].References[1] != stopped || caller.requests[0].Deadline.IsZero() { + t.Fatalf("batch was not one bounded helper request: err=%v %+v", err, caller.requests) + } + sample := results[0].Sample + if results[0].Err != nil || !sample.ObservedAt.Equal(now) || !sample.StartedAt.Equal(started) || + *sample.CPUUtilizationRatio != .1955 || *sample.CPUCapacityCores != 2 || sample.CPUUsageSecondsTotal != nil || + *sample.MemoryUsageBytes != memoryUsed || *sample.MemoryLimitBytes != memoryTotal || + *sample.DiskUsageBytes != diskUsed || *sample.DiskLimitBytes != diskTotal { + t.Fatalf("metrics were not mapped: %+v %v", sample, results[0].Err) + } + if !errors.Is(results[1].Err, runtimeobs.ErrNotRunning) { + t.Fatalf("absent sandbox = %v", results[1].Err) + } + + caller.response.Observations[0].DiskTotal = nil + results, _ = p.ObserveBatch(bounded(t), targets) + if results[0].Err != nil || results[0].Sample.DiskUsageBytes != nil || results[0].Sample.DiskLimitBytes != nil { + t.Fatalf("unreported disk was not null: %+v %v", results[0].Sample, results[0].Err) + } + caller.response.Observations[0].MemTotal = nil + results, _ = p.ObserveBatch(bounded(t), targets) + if !errors.Is(results[0].Err, runtimeobs.ErrUnavailable) || !errors.Is(results[1].Err, runtimeobs.ErrNotRunning) { + t.Fatalf("a malformed point affected more than its row: %+v", results) + } + caller.response.ErrorCode = "unconfirmed" + results, _ = p.ObserveBatch(bounded(t), targets) + if !errors.Is(results[0].Err, runtimeobs.ErrUnavailable) || !errors.Is(results[1].Err, runtimeobs.ErrUnavailable) { + t.Fatalf("E2B failure was not unavailable: %+v", results) + } +} diff --git a/services/core/internal/sandbox/e2b/operations.go b/services/core/internal/sandbox/e2b/operations.go new file mode 100644 index 000000000..f855e7d5f --- /dev/null +++ b/services/core/internal/sandbox/e2b/operations.go @@ -0,0 +1,65 @@ +package e2b + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var _ sandbox.CheckpointProvider = (*Provider)(nil) +var _ sandbox.SelectionDiscoverer = (*Provider)(nil) +var _ sandbox.CredentialVerifier = (*Provider)(nil) +var _ runtimeobs.BatchSource = (*Provider)(nil) + +// Operations is this adapter's complete authored resource contract. +func Operations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "Suspend": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "Resume": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "Observe": {State: providercontract.Supported}, + "ObserveBatch": {State: providercontract.Supported}, + "DiscoverSelection": {State: providercontract.Supported}, + "VerifyCredential": {State: providercontract.Supported}, + } +} +func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } +func (p *Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: Operations()["Initial"].Reason} +} +func (p *Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: Operations()["NewCompute"].Reason} +} +func (p *Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: Operations()["GetCompute"].Reason} +} +func (p *Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: Operations()["Suspend"].Reason} +} +func (p *Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: Operations()["Resume"].Reason} +} +func (p *Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: Operations()["KillCompute"].Reason} +} +func (p *Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: Operations()["DeleteSnapshot"].Reason} +} +func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: Operations()["RunCommandCompute"].Reason} +} +func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} +} diff --git a/services/core/internal/sandbox/e2b/process.go b/services/core/internal/sandbox/e2b/process.go new file mode 100644 index 000000000..a281bf5bb --- /dev/null +++ b/services/core/internal/sandbox/e2b/process.go @@ -0,0 +1,86 @@ +package e2b + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// ProcessCaller retains and drains an outstanding helper after caller timeout. +// The helper keeps its allocation lock until its bounded SDK operation settles. +var errHelperNotStarted = errors.New("helper did not start") + +type ProcessCaller struct{ Fence *sandbox.CallFence } + +func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { + data, err := json.Marshal(q) + if err != nil || len(data) > MaxRequestBytes { + return Response{}, errHelperNotStarted + } + cmd := exec.Command(q.Config.Binary) + cmd.Stdin = bytes.NewReader(data) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if key == "HOME" || key == "PATH" || key == "TMPDIR" || key == "LANG" || key == "SSL_CERT_FILE" || key == "SSL_CERT_DIR" { + cmd.Env = append(cmd.Env, entry) + } + } + cmd.Env = append(cmd.Env, "PYTHONNOUSERSITE=1", "PYTHONDONTWRITEBYTECODE=1") + stdout, stderr := &limitBuffer{limit: MaxResponseBytes}, &limitBuffer{limit: MaxOutputBytes} + cmd.Stdout, cmd.Stderr = stdout, stderr + if ctx.Err() != nil { + return Response{}, errHelperNotStarted + } + if cmd.Start() != nil { + return Response{}, errHelperNotStarted + } + finished := func() {} + if p.Fence != nil { + finished = p.Fence.ChildStarted() + } + done := make(chan error, 1) + go func() { err := cmd.Wait(); finished(); done <- err }() + select { + case <-ctx.Done(): + return Response{}, ctx.Err() + case err = <-done: + if err != nil || stdout.exceeded || stderr.exceeded { + return Response{}, errors.New("helper result unconfirmed") + } + } + var out Response + decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) + decoder.DisallowUnknownFields() + if decoder.Decode(&out) != nil { + return Response{}, errors.New("invalid helper response") + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return Response{}, errors.New("trailing helper response") + } + return out, nil +} + +type limitBuffer struct { + bytes.Buffer + limit int + exceeded bool +} + +func (b *limitBuffer) Write(data []byte) (int, error) { + n := len(data) + left := b.limit - b.Len() + if n > left { + b.exceeded = true + data = data[:left] + } + _, _ = b.Buffer.Write(data) + return n, nil +} diff --git a/services/core/internal/sandbox/e2b/process_test.go b/services/core/internal/sandbox/e2b/process_test.go new file mode 100644 index 000000000..c29864028 --- /dev/null +++ b/services/core/internal/sandbox/e2b/process_test.go @@ -0,0 +1,128 @@ +package e2b + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestProcessWireRejectsUnknownAndTrailingOutput(t *testing.T) { + for _, output := range []string{`{"Version":1,"Secret":"private"}`, `{"Version":1} {"Version":1}`} { + root := t.TempDir() + binary := filepath.Join(root, "helper") + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+output+"'\n"), 0700); err != nil { + t.Fatal(err) + } + if _, err := (&ProcessCaller{}).Call(bounded(t), Request{Config: Config{Binary: binary}}); err == nil || strings.Contains(err.Error(), "private") { + t.Fatal("invalid helper response accepted or leaked", err) + } + } +} +func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "helper") + marker := filepath.Join(root, "settled") + // The private test path contains no shell syntax; the real adapter never puts + // credentials or request contents in argv or inherited environment. + script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":1}'\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if _, err := (&ProcessCaller{}).Call(ctx, Request{Config: Config{Binary: binary}}); err == nil { + t.Fatal("timeout not reported") + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(marker); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("helper was killed before it could settle") +} +func TestEnvironmentDropsProviderSelectorsAndCredentials(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "helper") + script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":1}'\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("E2B_API_KEY", "secret") + t.Setenv("E2B_API_URL", "https://wrong.example") + t.Setenv("PRIVATE_MODEL_KEY", "secret") + if _, err := (&ProcessCaller{}).Call(bounded(t), Request{Config: Config{Binary: binary}}); err != nil { + t.Fatal(err) + } +} + +func TestCredentialFenceWaitsForActualHelperExitAfterCancellation(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "helper") + marker := filepath.Join(root, "started") + finish := filepath.Join(root, "finish") + script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":1}'\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + fence := &sandbox.CallFence{} + ctx, cancel := context.WithCancel(t.Context()) + entered, err := fence.Enter(ctx) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := (&ProcessCaller{Fence: fence}).Call(ctx, Request{Config: Config{Binary: binary}}) + entered() + done <- err + }() + t.Cleanup(func() { os.WriteFile(finish, nil, 0600); cancel() }) + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("helper did not start") + } + time.Sleep(time.Millisecond) + } + cancel() + if err := <-done; err == nil { + t.Fatal("caller cancellation hidden") + } + blocked, stop := context.WithTimeout(t.Context(), 30*time.Millisecond) + defer stop() + if unlock, err := fence.Fence(blocked); err == nil { + unlock() + t.Fatal("credential fence forgot a live helper") + } + // A failed exclusive waiter must return its permits immediately. + if release, err := fence.Enter(t.Context()); err != nil { + t.Fatal(err) + } else { + release() + } + if err := os.WriteFile(finish, nil, 0600); err != nil { + t.Fatal(err) + } + final, end := context.WithTimeout(t.Context(), 3*time.Second) + defer end() + release, err := fence.Fence(final) + if err != nil { + t.Fatal("late helper exit did not release fence", err) + } + release() + if release, err := fence.Enter(final); err != nil { + t.Fatal(err) + } else { + release() + } +} diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go new file mode 100644 index 000000000..4e04394d2 --- /dev/null +++ b/services/core/internal/sandbox/e2b/provider.go @@ -0,0 +1,328 @@ +// Package e2b adapts the official SDK helper to managed compute operations. +package e2b + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +const ProtocolVersion = 1 +const SDKVersion = "2.51.0" +const MaxOutputBytes = 1024 * 1024 +const MaxRequestBytes = 72 * 1024 * 1024 +const MaxResponseBytes = 16 * 1024 * 1024 + +// Config contains trusted deployment configuration; APIKey travels only on stdin. +type Config struct { + Binary, StateDir, InstallationID, APIKey, Template, APIURL, Domain string + TimeoutSeconds int + Resources *sandbox.Resources +} + +type Request struct { + Version int + Operation string + Config Config + Reference sandbox.Reference + // References lists the allocations of one read-only observe request. + References []sandbox.Reference `json:",omitempty"` + Bootstrap *sandbox.Bootstrap `json:",omitempty"` + RuntimeBootstrap *runtimebootstrap.Connection `json:",omitempty"` + Command *sandbox.Command `json:",omitempty"` + Deadline time.Time +} +type Response struct { + Version int + Info *sandbox.Info `json:",omitempty"` + Command *sandbox.CommandResult `json:",omitempty"` + ErrorCode string + DeploymentValid bool `json:",omitempty"` + TemplateBuild *TemplateBuild `json:",omitempty"` + Templates []TemplateSummary `json:",omitempty"` + Builds []ReadyBuild `json:",omitempty"` + Observations []Observation `json:",omitempty"` +} + +type TemplateSummary struct { + ID string `json:"id"` + Names []string `json:"names"` +} +type ReadyBuild struct { + ID string `json:"id"` + CPUs uint32 `json:"cpus"` + MemoryMiB uint32 `json:"memory_mib"` +} + +// Discover uses the same pinned SDK helper without requiring a saved deployment. +// Its credential is passed only to the helper on stdin. +func Discover(ctx context.Context, caller Caller, binary, apiKey, apiURL, domain, template string) (Response, error) { + if caller == nil || !filepath.IsAbs(binary) || apiKey == "" || len(apiKey) > 4096 || + strings.ContainsFunc(apiKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) { + return Response{}, sandbox.ErrInvalid + } + if _, _, err := NormalizeEndpoint(apiURL, domain); err != nil { + return Response{}, sandbox.ErrInvalid + } + operation := "list_templates" + if template != "" { + if len(template) > 128 { + return Response{}, sandbox.ErrInvalid + } + for _, r := range template { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + return Response{}, sandbox.ErrInvalid + } + } + operation = "list_builds" + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + out, err := caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: Config{Binary: binary, APIKey: apiKey, APIURL: apiURL, Domain: domain, Template: template}, Deadline: deadline}) + if err != nil || out.Version != ProtocolVersion { + return Response{}, sandbox.ErrComputeUnconfirmed + } + if out.ErrorCode == "invalid" { + return Response{}, sandbox.ErrInvalid + } + if out.ErrorCode != "" || out.Info != nil || out.Command != nil || out.TemplateBuild != nil || out.Observations != nil { + return Response{}, sandbox.ErrComputeUnconfirmed + } + if operation == "list_templates" { + if out.Templates == nil || out.Builds != nil || len(out.Templates) > 200 { + return Response{}, sandbox.ErrComputeUnconfirmed + } + for _, item := range out.Templates { + if item.ID == "" || len(item.ID) > 128 || len(item.Names) > 20 { + return Response{}, sandbox.ErrComputeUnconfirmed + } + for _, r := range item.ID { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + return Response{}, sandbox.ErrComputeUnconfirmed + } + } + for _, name := range item.Names { + if len(name) > 128 { + return Response{}, sandbox.ErrComputeUnconfirmed + } + } + } + } else { + if out.Builds == nil || out.Templates != nil || len(out.Builds) > 200 { + return Response{}, sandbox.ErrComputeUnconfirmed + } + for _, item := range out.Builds { + if !validID(item.ID) || item.CPUs == 0 || item.MemoryMiB == 0 { + return Response{}, sandbox.ErrComputeUnconfirmed + } + } + } + return out, nil +} + +// TemplateBuild is the fixed build as read by deployment validation. +// RootDiskMiB is nil when E2B does not report the build's disk size. +type TemplateBuild struct { + Status string + CPUs, MemoryMiB uint32 + RootDiskMiB *uint32 +} +type Caller interface { + Call(context.Context, Request) (Response, error) +} +type Provider struct { + config Config + caller Caller + now func() time.Time +} + +var _ sandbox.SandboxProvider = (*Provider)(nil) + +func validID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func (c Config) Validate() error { + if c.Resources != nil && ValidateResources(*c.Resources) != nil { + return sandbox.ErrInvalid + } + if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { + return sandbox.ErrInvalid + } + if err := ValidateConfiguration(&sandbox.E2BConfiguration{APIKey: c.APIKey, Template: c.Template, APIURL: c.APIURL, Domain: c.Domain}); err != nil { + return err + } + for _, path := range []string{c.Binary, c.StateDir} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return sandbox.ErrInvalid + } + } + binary, err := os.Stat(c.Binary) + if err != nil || !binary.Mode().IsRegular() || binary.Mode().Perm()&0111 == 0 { + return sandbox.ErrInvalid + } + state, err := os.Lstat(c.StateDir) + if err != nil || !state.IsDir() || state.Mode().Perm()&0077 != 0 { + return sandbox.ErrInvalid + } + return nil +} +func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } +func NewWithCaller(c Config, caller Caller) (*Provider, error) { + if c.Validate() != nil || caller == nil { + return nil, sandbox.ErrInvalid + } + if c.Resources != nil { + resources := *c.Resources + c.Resources = &resources + } + return &Provider{config: c, caller: caller, now: time.Now}, nil +} +func (p *Provider) call(ctx context.Context, operation string, r sandbox.Reference, b *sandbox.Bootstrap, command *sandbox.Command) (Response, error) { + deadline, ok := ctx.Deadline() + if !ok || (operation != "validate_deployment" && !validReference(r)) { + return unstarted(operation, r), sandbox.ErrInvalid + } + if err := ctx.Err(); err != nil { + return unstarted(operation, r), err + } + var connection *runtimebootstrap.Connection + if b != nil { + value := b.RuntimeConnection() + if value.Validate() != nil { + return unstarted(operation, r), sandbox.ErrInvalid + } + connection = &value + } + out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, RuntimeBootstrap: connection, Command: command, Deadline: deadline}) + if errors.Is(err, errHelperNotStarted) { + return unstarted(operation, r), sandbox.ErrComputeUnconfirmed + } + if err != nil || out.Version != ProtocolVersion { + if operation == "command" { + return Response{}, sandbox.ErrCommandUnconfirmed + } + return Response{}, sandbox.ErrComputeUnconfirmed + } + if out.Info != nil && (out.Info.Reference != r || len(out.Info.ProviderID) > 256 || len(out.Info.State) > 64 || out.Info.BootstrapComplete && !out.Info.CreateSettled) { + return Response{}, sandbox.ErrComputeUnconfirmed + } + switch out.ErrorCode { + case "": + return out, nil + case "template_invalid": + return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) + case "team_mismatch": + return out, ErrTeamMismatch + case "unauthorized": + return out, ErrCredentialInvalid + case "invalid": + return out, sandbox.ErrInvalid + case "ownership": + return out, sandbox.ErrOwnership + case "exists": + return out, sandbox.ErrExists + case "not_found": + return out, sandbox.ErrNotFound + case "command_unconfirmed": + return out, sandbox.ErrCommandUnconfirmed + default: + return out, sandbox.ErrComputeUnconfirmed + } +} + +// ValidateDeployment verifies team ownership and reads the exact immutable build without creating compute or +// allocation receipts. Candidate configuration remains unpublished until it passes. +// It returns the build as read. Without configured Resources it only requires a +// ready build, whose CPU and memory the caller then adopts as the selection. +func (p *Provider) ValidateDeployment(ctx context.Context) (TemplateBuild, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := p.call(ctx, "validate_deployment", sandbox.Reference{}, nil, nil) + if err != nil { + return TemplateBuild{}, err + } + build := out.TemplateBuild + if !out.DeploymentValid || out.Info != nil || out.Command != nil || out.Observations != nil || build == nil || build.Status != "ready" || + build.CPUs == 0 || build.MemoryMiB == 0 || build.RootDiskMiB != nil && *build.RootDiskMiB == 0 || + p.config.Resources != nil && (build.CPUs != p.config.Resources.CPUs || build.MemoryMiB != p.config.Resources.MemoryMiB) { + return TemplateBuild{}, sandbox.ErrComputeUnconfirmed + } + return *build, nil +} +func (p *Provider) info(ctx context.Context, operation string, r sandbox.Reference, b *sandbox.Bootstrap) (sandbox.Info, error) { + out, err := p.call(ctx, operation, r, b, nil) + if out.Info != nil { + return *out.Info, err + } + if err == nil { + err = sandbox.ErrComputeUnconfirmed + } + return sandbox.Info{Reference: r}, err +} +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} + if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || policy.Validate() != nil || b.RuntimeConnection().Validate() != nil { + info := sandbox.Info{Reference: b.Reference} + if validReference(b.Reference) { + info.State, info.CreateSettled = "absent", true + } + return info, sandbox.ErrInvalid + } + b.AllowedDomains = policy.Hosts() + return p.info(ctx, "create", b.Reference, &b) +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.info(ctx, "inspect", r, nil) +} +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.info(ctx, "renew", r, nil) +} +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + out, err := p.call(ctx, "kill", r, nil, nil) + if err == nil && (out.Info == nil || !out.Info.CreateSettled || out.Info.State != "absent") { + return sandbox.ErrComputeUnconfirmed + } + return err +} +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + if len(c.Args) == 0 || len(c.Stdin) > sandbox.MaxCommandInputBytes || c.Directory != "" && !filepath.IsAbs(c.Directory) { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + for _, arg := range c.Args { + if strings.ContainsRune(arg, 0) { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + } + out, err := p.call(ctx, "command", r, nil, &c) + if err != nil { + return sandbox.CommandResult{}, err + } + if out.Command == nil || len(out.Command.Stdout) > MaxOutputBytes || len(out.Command.Stderr) > MaxOutputBytes { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + return *out.Command, nil +} + +// A fresh allocation Create rejected before process startup has no cloud effects. +// The common Provider contract forbids replaying an earlier unknown Create. +func unstarted(operation string, r sandbox.Reference) Response { + if operation == "create" && validReference(r) { + return Response{Info: &sandbox.Info{Reference: r, State: "absent", CreateSettled: true}} + } + return Response{} +} diff --git a/services/core/internal/sandbox/e2b/provider_test.go b/services/core/internal/sandbox/e2b/provider_test.go new file mode 100644 index 000000000..406160671 --- /dev/null +++ b/services/core/internal/sandbox/e2b/provider_test.go @@ -0,0 +1,171 @@ +package e2b + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +type fakeCaller struct { + response Response + err error + requests []Request +} + +func (f *fakeCaller) Call(_ context.Context, q Request) (Response, error) { + f.requests = append(f.requests, q) + return f.response, f.err +} +func fixture(t *testing.T) (*Provider, *fakeCaller, sandbox.Reference) { + t.Helper() + root := t.TempDir() + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + binary := filepath.Join(root, "helper") + if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + config := Config{Binary: binary, StateDir: root, InstallationID: uuid.NewString(), APIKey: "private-test-key", Template: "test:" + uuid.NewString(), TimeoutSeconds: 300} + caller := &fakeCaller{response: Response{Version: ProtocolVersion}} + provider, err := NewWithCaller(config, caller) + if err != nil { + t.Fatal(err) + } + return provider, caller, sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} +} +func bounded(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + t.Cleanup(cancel) + return ctx +} +func TestReadOnlyConstructionAndValidation(t *testing.T) { + p, f, _ := fixture(t) + if len(f.requests) != 0 { + t.Fatal("construction invoked helper") + } + for _, change := range []func(*Config){func(c *Config) { c.Template = "mutable" }, func(c *Config) { c.APIKey = "key\n" }, func(c *Config) { c.StateDir = "relative" }, func(c *Config) { c.TimeoutSeconds = 0 }, func(c *Config) { c.Binary = "/missing/helper" }} { + c := p.config + change(&c) + if _, err := NewWithCaller(c, f); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("invalid configuration accepted") + } + } +} +func TestReferenceBoundSettlement(t *testing.T) { + p, f, r := fixture(t) + f.response.Info = &sandbox.Info{Reference: r, State: "absent", CreateSettled: true} + info, err := p.GetInfo(bounded(t), r) + if err != nil || !info.CreateSettled || info.State != "absent" { + t.Fatalf("absence proof: %+v %v", info, err) + } + f.response.Info.AllocationID = uuid.NewString() + if _, err = p.GetInfo(bounded(t), r); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatal("foreign proof accepted", err) + } +} +func TestUnknownDoesNotLeakHelperDiagnosticOrProveAbsence(t *testing.T) { + p, f, r := fixture(t) + f.err = errors.New("private-account-key in SDK response") + info, err := p.GetInfo(bounded(t), r) + if !errors.Is(err, sandbox.ErrComputeUnconfirmed) || info.CreateSettled { + t.Fatal(info, err) + } + if err.Error() != "sandbox lifecycle outcome unconfirmed" { + t.Fatal("helper diagnostic leaked") + } + f.err = nil + f.response.ErrorCode = "not_found" + if _, err = p.GetInfo(bounded(t), r); !errors.Is(err, sandbox.ErrNotFound) { + t.Fatal(err) + } +} +func TestKillRequiresTerminalProof(t *testing.T) { + p, f, r := fixture(t) + for _, info := range []*sandbox.Info{nil, {Reference: r, State: "absent"}, {Reference: r, State: "running", CreateSettled: true}} { + f.response.Info = info + if !errors.Is(p.Kill(bounded(t), r), sandbox.ErrComputeUnconfirmed) { + t.Fatal("unproven cleanup accepted") + } + } + f.response.Info = &sandbox.Info{Reference: r, State: "absent", CreateSettled: true} + if err := p.Kill(bounded(t), r); err != nil { + t.Fatal(err) + } +} +func TestCreateAndCommandUseOnlyPrivateRequest(t *testing.T) { + p, f, r := fixture(t) + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "private-bootstrap", NetworkAccess: "enabled"} + f.response.Info = &sandbox.Info{Reference: r, State: "running", ProviderID: "native-id", CreateSettled: true, BootstrapComplete: true} + if _, err := p.Create(bounded(t), b); err != nil { + t.Fatal(err) + } + q := f.requests[0] + if q.Operation != "create" || q.Bootstrap == nil || q.Bootstrap.Credential != b.Credential || q.Config.APIKey != p.config.APIKey { + t.Fatal("private request lost") + } + f.response.Info = nil + f.response.Command = &sandbox.CommandResult{ExitCode: 7, Stdout: "output"} + got, err := p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"cat"}, Stdin: []byte("private input")}) + if err != nil || got.ExitCode != 7 { + t.Fatal(got, err) + } + f.err = context.DeadlineExceeded + if _, err = p.RunCommand(bounded(t), r, sandbox.Command{Args: []string{"true"}}); !errors.Is(err, sandbox.ErrCommandUnconfirmed) { + t.Fatal(err) + } + if len(f.requests) != 3 { + t.Fatal("operation was replayed") + } +} +func TestDeadlineAndCommandAdmission(t *testing.T) { + p, f, r := fixture(t) + if _, err := p.GetInfo(context.Background(), r); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal(err) + } + for _, cmd := range []sandbox.Command{{}, {Args: []string{"x\x00"}}, {Args: []string{"cat"}, Directory: "relative"}} { + if _, err := p.RunCommand(bounded(t), r, cmd); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal(err) + } + } + if len(f.requests) != 0 { + t.Fatal("invalid operation reached helper") + } +} + +func TestDefinitePreHelperCreateFailureCarriesAbsenceProof(t *testing.T) { + p, f, r := fixture(t) + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "private", NetworkAccess: "enabled"} + for _, err := range []error{errHelperNotStarted, context.DeadlineExceeded} { + f.err = err + info, gotErr := p.Create(bounded(t), b) + if gotErr == nil { + t.Fatal("failure lost") + } + if errors.Is(err, errHelperNotStarted) { + if !info.CreateSettled || info.State != "absent" { + t.Fatal("missing definite absence proof") + } + } else if info.CreateSettled { + t.Fatal("timeout proved absence") + } + } +} + +func TestInvalidTemplateHasSafeActionableDiagnostic(t *testing.T) { + p, f, r := fixture(t) + f.response.ErrorCode = "template_invalid" + f.response.Info = &sandbox.Info{Reference: r, ProviderID: "owned", State: "running", CreateSettled: true} + info, err := p.GetInfo(bounded(t), r) + if !errors.Is(err, sandbox.ErrInvalid) || !strings.Contains(err.Error(), "Build a template with this release's build-template.py") || !info.CreateSettled { + t.Fatalf("invalid template: %+v %v", info, err) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/selection_test.go b/services/core/internal/sandbox/e2b/selection_test.go similarity index 92% rename from services/agents-api/internal/sandbox/e2b/selection_test.go rename to services/core/internal/sandbox/e2b/selection_test.go index b225eb7e5..2b77e2675 100644 --- a/services/agents-api/internal/sandbox/e2b/selection_test.go +++ b/services/core/internal/sandbox/e2b/selection_test.go @@ -3,7 +3,7 @@ package e2b import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func TestOmittedE2BResourcesComeFromValidatedTemplateBuild(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/generation.go b/services/core/internal/sandbox/generation.go similarity index 100% rename from services/agents-api/internal/sandbox/generation.go rename to services/core/internal/sandbox/generation.go diff --git a/services/core/internal/sandbox/microsandbox/contract_test.go b/services/core/internal/sandbox/microsandbox/contract_test.go new file mode 100644 index 000000000..e4b11b963 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/contract_test.go @@ -0,0 +1,72 @@ +package microsandbox + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/contracttest" + "github.com/google/uuid" + "testing" +) + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + c, r := testConfig(), testRef() + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p, err := NewWithCaller(c, callerFunc(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, foreign, 0), ID: "foreign"}, Status: "running", BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + })) + if err != nil { + t.Fatal(err) + } + nativeOperation := s.Operation + if nativeOperation == "renew" { + nativeOperation = "inspect" + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{nativeOperation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + c, r := testConfig(), testRef() + p, err := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + if q.Operation != "inspect" && q.Operation != "create" { + t.Fatal("observation mutated compute") + } + status := "stopped" + if q.Operation == "create" { + status = "running" + } + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, r, 0), ID: "native-owned"}, Status: status}}, nil + })) + if err != nil { + t.Fatal(err) + } + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(deadline(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") + got, err = p.Renew(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") +} diff --git a/services/core/internal/sandbox/microsandbox/creation_settlement_test.go b/services/core/internal/sandbox/microsandbox/creation_settlement_test.go new file mode 100644 index 000000000..0ac26e6f9 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/creation_settlement_test.go @@ -0,0 +1,83 @@ +package microsandbox + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestCreateConfigurationRejectionSettlement(t *testing.T) { + config, ref := testConfig(), testRef() + bootstrap := sandbox.Bootstrap{Reference: ref, SessionID: ref.TenantID, DeviceID: ref.EnvironmentID, + CoreURL: "https://core.example/api/v1", Credential: "fixture", NetworkAccess: "disabled"} + for _, test := range []struct { + name string + change func(*Response) + failure error + settled bool + }{ + {name: "exact initial compute", settled: true}, + {name: "no proof", change: func(r *Response) { r.CreateSettled = false }}, + {name: "no state", change: func(r *Response) { r.State = nil }}, + {name: "no native identity", change: func(r *Response) { r.State.Compute.ID = "" }}, + {name: "foreign allocation", change: func(r *Response) { r.State.Compute.Name = "foreign" }}, + {name: "foreign generation", change: func(r *Response) { r.State.Compute.Generation = 1 }}, + {name: "restored compute", change: func(r *Response) { value := testSnapshot(); r.State.Compute.RestoredFrom = &value }}, + {name: "bootstrap already complete", change: func(r *Response) { r.State.BootstrapComplete = true }}, + {name: "absence is not proof", change: func(r *Response) { r.State.Status = "absent" }}, + {name: "missing status", change: func(r *Response) { r.State.Status = "" }}, + {name: "wrong protocol", change: func(r *Response) { r.Version++ }}, + {name: "ownership rejection", change: func(r *Response) { r.ErrorCode = "ownership" }}, + {name: "unknown create", change: func(r *Response) { r.ErrorCode = "unconfirmed" }}, + {name: "lost response", failure: context.DeadlineExceeded}, + } { + t.Run(test.name, func(t *testing.T) { + response := Response{Version: ProtocolVersion, CreateSettled: true, ErrorCode: "invalid", + State: &State{Compute: Compute{Name: Name(config, ref, 0), ID: "local:created"}, Status: "running"}} + if test.change != nil { + test.change(&response) + } + calls := 0 + provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { + calls++ + if request.Operation != "create" || request.Reference != ref { + t.Fatal("changed creation request") + } + return response, test.failure + })) + if err != nil { + t.Fatal(err) + } + info, err := provider.Create(deadline(t), bootstrap) + if err == nil || info.CreateSettled != test.settled || info.BootstrapComplete || calls != 1 { + t.Fatal("rejection changed readiness or settlement", info, err, calls) + } + if test.settled && (!errors.Is(err, sandbox.ErrInvalid) || info.Reference != ref || info.ProviderID != "local:created" || info.State == "absent") { + t.Fatal("settlement lost the original rejection or compute identity", info, err) + } + }) + } +} + +func TestNonCreateResponseCannotSettleCreation(t *testing.T) { + for _, code := range []string{"", "invalid", "ownership", "unconfirmed"} { + t.Run(code, func(t *testing.T) { + config, ref := testConfig(), testRef() + provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, q Request) (Response, error) { + return Response{Version: ProtocolVersion, ErrorCode: code, CreateSettled: true, + State: &State{Compute: Compute{Name: Name(config, ref, 0), ID: "local:observed"}, Status: "running"}}, nil + })) + if err != nil { + t.Fatal(err) + } + for _, read := range []func(context.Context, sandbox.Reference) (sandbox.Info, error){provider.GetInfo, provider.Renew} { + info, err := read(deadline(t), ref) + if (err == nil) != (code == "") || info.CreateSettled { + t.Fatal("ordinary inspection acquired creation settlement", info, err) + } + } + }) + } +} diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go new file mode 100644 index 000000000..a5063e214 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -0,0 +1,12 @@ +package microsandbox + +import ( + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{Disk: true, Runtime: true} } + +func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } +func ValidateSpecification(s sandbox.DeploymentSpec) error { + return s.ValidatePolicy("microsandbox", Policy()) +} diff --git a/services/core/internal/sandbox/microsandbox/identity.go b/services/core/internal/sandbox/microsandbox/identity.go new file mode 100644 index 000000000..2b95e1628 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/identity.go @@ -0,0 +1,136 @@ +package microsandbox + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func validID(s string) bool { + v, e := uuid.Parse(s) + return e == nil && v != uuid.Nil && v.String() == s +} +func ValidReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func validHash(s string) bool { + b, e := hex.DecodeString(s) + return e == nil && len(b) == 32 && strings.ToLower(s) == s +} +func (c Config) Validate() error { + if !validID(c.InstallationID) || !filepath.IsAbs(c.HelperPath) || !filepath.IsAbs(c.RuntimeHome) || !filepath.IsAbs(c.RuntimePath) || !filepath.IsAbs(c.FirmwarePath) || !validHash(c.RuntimeSHA256) || !validHash(c.FirmwareSHA256) || c.MemoryMiB == 0 || c.CPUs == 0 || c.RootDiskMiB == 0 || c.EnvironmentDiskMiB == 0 { + return sandbox.ErrInvalid + } + imageName, digest, pinned := strings.Cut(c.Image, "@sha256:") + if !pinned || imageName == "" || strings.Contains(imageName, "@") || !validHash(digest) { + return sandbox.ErrInvalid + } + if c.Network.DefaultEgress != "allow" && c.Network.DefaultEgress != "deny" { + return sandbox.ErrInvalid + } + if c.Network.DefaultIngress != "allow" && c.Network.DefaultIngress != "deny" { + return sandbox.ErrInvalid + } + for _, r := range c.Network.Rules { + if (r.Action != "allow" && r.Action != "deny") || (r.Direction != "egress" && r.Direction != "ingress") || r.Destination == "" { + return sandbox.ErrInvalid + } + } + return nil +} +func Name(c Config, r sandbox.Reference, g uint64) string { + h := sha256.Sum256([]byte(c.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) + return fmt.Sprintf("oac-%x-g%d", h[:16], g) +} +func SnapshotReference(c Config, r sandbox.Reference, operation string) string { + return Name(c, r, 0) + ":s-" + operation +} +func Labels(c Config, r sandbox.Reference) map[string]string { + return map[string]string{"io.oac.installation": c.InstallationID, "io.oac.tenant": r.TenantID, "io.oac.environment": r.EnvironmentID, "io.oac.allocation": r.AllocationID} +} +func ValidateCompute(c Config, r sandbox.Reference, v Compute) error { + if !ValidReference(r) || v.Name != Name(c, r, v.Generation) { + return sandbox.ErrInvalid + } + if v.Generation == 0 { + if v.RestoredFrom != nil { + return sandbox.ErrInvalid + } + } else { + if v.RestoredFrom == nil || ValidateSnapshot(c, r, *v.RestoredFrom) != nil || v.RestoredFrom.SourceGeneration >= v.Generation { + return sandbox.ErrInvalid + } + } + return nil +} +func ValidateSnapshot(c Config, r sandbox.Reference, s SnapshotIdentity) error { + if !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || s.Digest == "" || s.CheckpointID == "" || s.CheckpointRoot == "" { + return sandbox.ErrInvalid + } + return nil +} +func ValidateBootstrap(b sandbox.Bootstrap) error { + if !ValidReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { + return sandbox.ErrInvalid + } + return (agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains}).Validate() +} +func ValidateCommand(c sandbox.Command) error { + if len(c.Args) == 0 || c.Args[0] == "" || len(c.Stdin) > sandbox.MaxCommandInputBytes || (c.Directory != "" && !filepath.IsAbs(c.Directory)) { + return sandbox.ErrInvalid + } + for _, a := range c.Args { + if strings.IndexByte(a, 0) >= 0 { + return sandbox.ErrInvalid + } + } + return nil +} +func ValidateRequest(q Request) error { + if q.Version != ProtocolVersion || q.Config.Validate() != nil || !ValidReference(q.Reference) || q.Deadline.IsZero() { + return sandbox.ErrInvalid + } + switch q.Operation { + case "create": + if q.Bootstrap == nil || q.Bootstrap.Reference != q.Reference || ValidateBootstrap(*q.Bootstrap) != nil { + return sandbox.ErrInvalid + } + case "inspect", "kill", "resume_compute", "metrics": + if q.Operation == "resume_compute" && q.Compute.ID == "" { + return sandbox.ErrInvalid + } + return ValidateCompute(q.Config, q.Reference, q.Compute) + case "command": + if q.Command == nil || ValidateCommand(*q.Command) != nil { + return sandbox.ErrInvalid + } + return ValidateCompute(q.Config, q.Reference, q.Compute) + case "suspend": + s := q.Suspend + if s == nil || s.Reference != q.Reference || !validID(s.OperationID) || s.Source.ID == "" || ValidateCompute(q.Config, q.Reference, s.Source) != nil { + return sandbox.ErrInvalid + } + if s.Snapshot != nil && (ValidateSnapshot(q.Config, q.Reference, *s.Snapshot) != nil || s.Snapshot.OperationID != s.OperationID || s.Snapshot.SourceID != s.Source.ID || s.Snapshot.SourceName != s.Source.Name || s.Snapshot.SourceGeneration != s.Source.Generation) { + return sandbox.ErrInvalid + } + case "resume": + v := q.Resume + if v == nil || v.Reference != q.Reference || !validID(v.OperationID) || ValidateSnapshot(q.Config, q.Reference, v.Snapshot) != nil || ValidateCompute(q.Config, q.Reference, v.Target) != nil || v.Target.RestoredFrom == nil || *v.Target.RestoredFrom != v.Snapshot { + return sandbox.ErrInvalid + } + case "delete_snapshot": + if q.Snapshot == nil { + return sandbox.ErrInvalid + } + return ValidateSnapshot(q.Config, q.Reference, *q.Snapshot) + default: + return sandbox.ErrInvalid + } + return nil +} diff --git a/services/core/internal/sandbox/microsandbox/operations.go b/services/core/internal/sandbox/microsandbox/operations.go new file mode 100644 index 000000000..053ca9af2 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/operations.go @@ -0,0 +1,47 @@ +package microsandbox + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var _ sandbox.CheckpointProvider = (*Provider)(nil) +var _ sandbox.SelectionDiscoverer = (*Provider)(nil) +var _ sandbox.CredentialVerifier = (*Provider)(nil) +var _ runtimeobs.BatchSource = (*Provider)(nil) + +// Operations is this adapter's complete authored resource contract. +func Operations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Supported}, + "NewCompute": {State: providercontract.Supported}, + "GetCompute": {State: providercontract.Supported}, + "Suspend": {State: providercontract.Supported}, + "Resume": {State: providercontract.Supported}, + "KillCompute": {State: providercontract.Supported}, + "DeleteSnapshot": {State: providercontract.Supported}, + "RunCommandCompute": {State: providercontract.Supported}, + "ResumeCompute": {State: providercontract.Supported}, + "Observe": {State: providercontract.Supported}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_batch_observation"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_selection_discovery"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "microsandbox_does_not_support_credential_verification"}, + } +} +func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } +func (p *Provider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: Operations()["ObserveBatch"].Reason} +} +func (p *Provider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { + return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: Operations()["DiscoverSelection"].Reason} +} +func (p *Provider) VerifyCredential(context.Context, []sandbox.Reference) error { + return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: Operations()["VerifyCredential"].Reason} +} diff --git a/services/core/internal/sandbox/microsandbox/process.go b/services/core/internal/sandbox/microsandbox/process.go new file mode 100644 index 000000000..ce3218740 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/process.go @@ -0,0 +1,233 @@ +package microsandbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" +) + +// ProcessCaller never kills a mutating helper on a Core response timeout. +// The helper retains its allocation flock until the SDK mutation settles, +// including after Core exits. Output is still drained and bounded by this waiter. +type ProcessCaller struct { + active atomic.Int64 + // LeasePath belongs to the permanent node state namespace, not a release. + // It is never unlinked, including after the generation is collected. + LeasePath string + LeaseIdentity LeaseIdentity +} + +func (p *ProcessCaller) Quiescent() bool { return p.active.Load() == 0 } + +func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { + data, e := json.Marshal(q) + if e != nil || len(data) > MaxRequestBytes { + return Response{}, errors.New("invalid helper request") + } + lease, err := p.acquireLease() + if err != nil { + return Response{}, errors.New("generation helper lease unavailable") + } + releaseLease := func() { + if lease != nil { + _ = lease.Close() + } + } + cmd := exec.Command(q.Config.HelperPath) + cmd.Stdin = bytes.NewReader(data) + cmd.Env = HelperEnvironment(os.Environ(), q.Config) + if lease != nil { + cmd.ExtraFiles = []*os.File{lease} + cmd.Env = append(cmd.Env, "OAC_NODE_GENERATION_LEASE_FD=3") + } + stdout := &limitBuffer{limit: MaxResponseBytes} + stderr := &limitBuffer{limit: MaxOutputBytes} + cmd.Stdout, cmd.Stderr = stdout, stderr + p.active.Add(1) + if e := cmd.Start(); e != nil { + p.active.Add(-1) + releaseLease() + return Response{}, errors.New("helper unavailable") + } + done := make(chan error, 1) + go func() { err := cmd.Wait(); releaseLease(); p.active.Add(-1); done <- err }() + select { + case <-ctx.Done(): + return Response{}, ctx.Err() + case err := <-done: + if err != nil || stdout.exceeded || stderr.exceeded { + return Response{}, errors.New("helper result unconfirmed") + } + } + var out Response + decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) + decoder.DisallowUnknownFields() + if e := decoder.Decode(&out); e != nil { + return Response{}, errors.New("invalid helper response") + } + var extra any + if e := decoder.Decode(&extra); e != io.EOF { + return Response{}, errors.New("trailing helper response") + } + return out, nil +} + +// Strip native selector and credential-bearing inherited configuration. Operator +// proxy settings are not passed into guest environments by this adapter. +func HelperEnvironment(env []string, c Config) []string { + out := []string{} + for _, v := range env { + key, _, _ := strings.Cut(v, "=") + if key == "HOME" || key == "PATH" || key == "TMPDIR" || key == "LANG" || key == "SSL_CERT_FILE" || key == "SSL_CERT_DIR" { + out = append(out, v) + } + } + return append(out, "MSB_HOME="+c.RuntimeHome, "MSB_PATH="+c.RuntimePath, "MSB_LIBKRUNFW_PATH="+c.FirmwarePath, "MSB_BACKEND=local", "RUST_LOG=off", "NO_COLOR=1") +} + +type limitBuffer struct { + bytes.Buffer + limit int + exceeded bool +} + +func (b *limitBuffer) Write(v []byte) (int, error) { + n := len(v) + left := b.limit - b.Len() + if n > left { + b.exceeded = true + v = v[:left] + } + _, _ = b.Buffer.Write(v) + return n, nil // Drain excess output without unbounded retention or pipe deadlock. +} + +// A shared open-file-description flock survives node exit through the inherited +// helper descriptor. Close only our descriptor; LOCK_UN would also unlock the +// helper's copy. Collection holds the exclusive lock before touching any bytes. +func (p *ProcessCaller) acquireLease() (*os.File, error) { + if p.LeasePath == "" { + return nil, nil + } + fd, err := syscall.Open(p.LeasePath, syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0600) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), p.LeasePath) + fail := func(err error) (*os.File, error) { _ = file.Close(); return nil, err } + info, err := file.Stat() + if err != nil { + return fail(err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || stat.Uid != uint32(os.Geteuid()) || stat.Nlink != 1 { + return fail(errors.New("invalid generation lease")) + } + if err := syscall.Flock(fd, syscall.LOCK_SH|syscall.LOCK_NB); err != nil { + return fail(err) + } + named, err := os.Lstat(p.LeasePath) + if err != nil || !os.SameFile(info, named) { + return fail(errors.New("generation lease replaced")) + } + if err := p.verifyLeaseIdentity(stat); err != nil { + return fail(err) + } + for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { + if _, err := os.Lstat(strings.TrimSuffix(p.LeasePath, ".lease") + suffix); !os.IsNotExist(err) { + return fail(errors.New("generation is not finalized and usable")) + } + } + if err := p.verifyFinalizedGeneration(); err != nil { + return fail(err) + } + return file, nil +} + +// LeaseIdentity is recorded by the installer before a generation can spawn a +// helper. Neither opener adopts a missing record or a replacement lease inode. +type LeaseIdentity struct { + InstallationID string `json:"installation_id"` + Generation uint64 `json:"generation"` + SpecificationDigest string `json:"specification_digest"` +} + +func (p *ProcessCaller) verifyLeaseIdentity(lease *syscall.Stat_t) error { + path := strings.TrimSuffix(p.LeasePath, ".lease") + ".lease-identity" + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Geteuid()) || st.Nlink != 1 || st.Size > 4096 { + return errors.New("invalid durable generation lease identity") + } + var saved struct { + LeaseIdentity + Device uint64 `json:"device"` + Inode uint64 `json:"inode"` + } + decoder := json.NewDecoder(io.LimitReader(file, 4097)) + decoder.DisallowUnknownFields() + if decoder.Decode(&saved) != nil || decoder.Decode(new(any)) != io.EOF || saved.LeaseIdentity != p.LeaseIdentity || saved.Generation == 0 || saved.InstallationID == "" || len(saved.SpecificationDigest) != 64 || saved.Device != uint64(lease.Dev) || saved.Inode != lease.Ino { + return errors.New("generation lease identity differs") + } + named, err := os.Lstat(path) + if err != nil || !os.SameFile(info, named) { + return errors.New("generation lease identity replaced") + } + return nil +} + +// A durable lease alone is not a published provider. Initial enrollment stores +// its final config at provider.json; later generations use .json. +func (p *ProcessCaller) verifyFinalizedGeneration() error { + path := strings.TrimSuffix(p.LeasePath, ".lease") + ".json" + if _, err := os.Lstat(path); os.IsNotExist(err) { + path = filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(p.LeasePath)))), "provider.json") + } + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Geteuid()) || st.Nlink != 1 || st.Size > 16384 { + return errors.New("invalid finalized generation") + } + var value struct { + InstallationID string `json:"installation_id"` + Generation uint64 `json:"generation"` + Provider string `json:"provider"` + Specification sandbox.DeploymentSpec `json:"specification"` + } + decoder := json.NewDecoder(io.LimitReader(file, 16385)) + if decoder.Decode(&value) != nil || decoder.Decode(new(any)) != io.EOF || value.InstallationID != p.LeaseIdentity.InstallationID || value.Generation != p.LeaseIdentity.Generation || value.Provider != "microsandbox" || value.Specification.Digest(value.Provider) != p.LeaseIdentity.SpecificationDigest { + return errors.New("finalized generation identity differs") + } + named, err := os.Lstat(path) + if err != nil || !os.SameFile(info, named) { + return errors.New("finalized generation was replaced") + } + return nil +} diff --git a/services/core/internal/sandbox/microsandbox/process_test.go b/services/core/internal/sandbox/microsandbox/process_test.go new file mode 100644 index 000000000..ed06dc6a5 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/process_test.go @@ -0,0 +1,448 @@ +package microsandbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestMain(m *testing.M) { + if filepath.Base(os.Args[0]) == "microsandbox-test-agent" { + var q Request + if json.NewDecoder(os.Stdin).Decode(&q) != nil { + os.Exit(2) + } + _, _ = (&ProcessCaller{LeasePath: q.Config.RuntimeHome, LeaseIdentity: testLeaseIdentity()}).Call(context.Background(), q) + os.Exit(0) + } + if filepath.Base(os.Args[0]) == "microsandbox-test-helper" { + var q Request + if json.NewDecoder(os.Stdin).Decode(&q) != nil { + os.Exit(2) + } + switch q.Operation { + case "lease-held": + // Model the new native helper's entrypoint before any SDK subprocess. + if os.Getenv("OAC_NODE_GENERATION_LEASE_FD") != "3" { + os.Exit(4) + } + syscall.CloseOnExec(3) + if os.WriteFile(q.Config.RuntimePath, []byte("started"), 0600) != nil { + os.Exit(3) + } + until := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(q.Config.RuntimePath + ".release"); err == nil { + break + } + if time.Now().After(until) { + os.Exit(5) + } + time.Sleep(time.Millisecond) + } + + case "delayed": + time.Sleep(150 * time.Millisecond) + if os.WriteFile(q.Config.RuntimePath, []byte("settled"), 0600) != nil { + os.Exit(3) + } + case "oversize": + fmt.Print(strings.Repeat("x", MaxResponseBytes+1)) + os.Exit(0) + case "trailing": + fmt.Print("{\"Version\":1}{}") + os.Exit(0) + } + _ = json.NewEncoder(os.Stdout).Encode(Response{Version: ProtocolVersion}) + os.Exit(0) + } + os.Exit(m.Run()) +} +func processConfig(t *testing.T) Config { + t.Helper() + c := testConfig() + directory := t.TempDir() + executable, e := os.Executable() + if e != nil { + t.Fatal(e) + } + c.HelperPath = filepath.Join(directory, "microsandbox-test-helper") + if e = os.Symlink(executable, c.HelperPath); e != nil { + t.Fatal(e) + } + c.RuntimePath = filepath.Join(directory, "settled") + return c +} +func TestResponseTimeoutDoesNotKillLifecycleOwner(t *testing.T) { + c := processConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + _, e := (&ProcessCaller{}).Call(ctx, Request{Operation: "delayed", Config: c}) + if !errors.Is(e, context.DeadlineExceeded) { + t.Fatalf("want timeout: %v", e) + } + until := time.Now().Add(3 * time.Second) + for time.Now().Before(until) { + if data, e := os.ReadFile(c.RuntimePath); e == nil && string(data) == "settled" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("helper was killed before settlement") +} +func TestInvalidOrUnboundedHelperOutputIsUnconfirmed(t *testing.T) { + for _, mode := range []string{"oversize", "trailing"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, e := (&ProcessCaller{}).Call(ctx, Request{Operation: mode, Config: processConfig(t)}) + if e == nil { + t.Fatal("invalid helper output accepted") + } + }) + } +} + +func TestGenerationLeaseSurvivesNodeProcessExit(t *testing.T) { + c := processConfig(t) + c.RuntimeHome = filepath.Join(filepath.Dir(c.RuntimePath), "1.lease") + writeTestLease(t, c.RuntimeHome) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + agent := filepath.Join(filepath.Dir(c.RuntimePath), "microsandbox-test-agent") + if err := os.Symlink(executable, agent); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(Request{Operation: "lease-held", Config: c}) + command := exec.Command(agent) + command.Stdin = strings.NewReader(string(raw)) + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = command.Process.Kill(); _ = command.Wait() }() + defer func() { + _ = os.WriteFile(c.RuntimePath+".release", nil, 0600) + waitProcessCondition(t, func() bool { return exclusiveLease(c.RuntimeHome) == nil }) + }() + waitProcessCondition(t, func() bool { _, err := os.Stat(c.RuntimePath); return err == nil }) + before, err := os.Stat(c.RuntimeHome) + if err != nil { + t.Fatal(err) + } + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = command.Wait() + // A new node has no in-memory references, but cannot collect the old helper. + restarted := &ProcessCaller{LeasePath: c.RuntimeHome, LeaseIdentity: testLeaseIdentity()} + if !restarted.Quiescent() { + t.Fatal("fresh process unexpectedly has references") + } + if err := exclusiveLease(c.RuntimeHome); !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatalf("live orphan helper lost lease: %v", err) + } + // A fresh node must also refuse an owned replacement regular inode while + // the actual orphan helper still holds the original open file description. + if err := os.Rename(c.RuntimeHome, c.RuntimeHome+".old"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.RuntimeHome, nil, 0600); err != nil { + t.Fatal(err) + } + if file, err := restarted.acquireLease(); err == nil { + file.Close() + t.Fatal("live helper bypassed through replacement inode") + } + if err := exclusiveLease(c.RuntimeHome + ".old"); !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal("original helper lock lost", err) + } + if err := os.Remove(c.RuntimeHome); err != nil { + t.Fatal(err) + } + if err := os.Rename(c.RuntimeHome+".old", c.RuntimeHome); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.RuntimePath+".release", nil, 0600); err != nil { + t.Fatal(err) + } + waitProcessCondition(t, func() bool { return exclusiveLease(c.RuntimeHome) == nil }) + after, err := os.Stat(c.RuntimeHome) + if err != nil || !os.SameFile(before, after) { + t.Fatal("lease inode changed", err) + } +} + +func exclusiveLease(path string) error { + file, err := os.OpenFile(path, os.O_RDWR, 0600) + if err != nil { + return err + } + defer file.Close() + return syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func waitProcessCondition(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("process condition did not settle") +} + +func TestGenerationLeaseExcludesCollectionAndDroppedGeneration(t *testing.T) { + c := processConfig(t) + lease := filepath.Join(filepath.Dir(c.RuntimePath), "1.lease") + writeTestLease(t, lease) + file, err := os.OpenFile(lease, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + caller := &ProcessCaller{LeasePath: lease, LeaseIdentity: testLeaseIdentity()} + if _, err := caller.Call(t.Context(), Request{Operation: "delayed", Config: c}); err == nil { + t.Fatal("helper started during exclusive collection") + } + if err := os.WriteFile(strings.TrimSuffix(lease, ".lease")+".dropped", []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + if _, err := caller.Call(t.Context(), Request{Operation: "delayed", Config: c}); err == nil { + t.Fatal("helper started after payload collection") + } + if _, err := os.Stat(c.RuntimePath); !os.IsNotExist(err) { + t.Fatal("refused helper mutated files", err) + } + // Never accept an alternate inode through symlinks or multiply linked files. + if err := os.Remove(lease); err != nil { + t.Fatal(err) + } + if err := os.Symlink(c.RuntimePath, lease); err != nil { + t.Fatal(err) + } + if _, err := caller.acquireLease(); err == nil { + t.Fatal("symlink lease accepted") + } +} + +func testLeaseIdentity() LeaseIdentity { + return LeaseIdentity{InstallationID: "test-installation", Generation: 1, SpecificationDigest: (sandbox.DeploymentSpec{}).Digest("microsandbox")} +} + +func writeTestLease(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + st := info.Sys().(*syscall.Stat_t) + value := struct { + LeaseIdentity + Device uint64 `json:"device"` + Inode uint64 `json:"inode"` + }{testLeaseIdentity(), uint64(st.Dev), st.Ino} + raw, _ := json.Marshal(value) + if err := os.WriteFile(strings.TrimSuffix(path, ".lease")+".lease-identity", raw, 0600); err != nil { + t.Fatal(err) + } + writeTestFinalGeneration(t, path) +} + +func writeTestFinalGeneration(t *testing.T, path string) { + t.Helper() + raw, _ := json.Marshal(map[string]any{"installation_id": "test-installation", "generation": 1, "provider": "microsandbox", "specification": sandbox.DeploymentSpec{}}) + if err := os.WriteFile(strings.TrimSuffix(path, ".lease")+".json", raw, 0600); err != nil { + t.Fatal(err) + } +} + +func TestGenerationLeaseReplacementRejectedAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.lease") + writeTestLease(t, path) + caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} + old, err := caller.acquireLease() + if err != nil { + t.Fatal(err) + } + defer old.Close() + if err := os.Rename(path, path+".old"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + restarted := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} + if file, err := restarted.acquireLease(); err == nil { + file.Close() + t.Fatal("replacement inode was adopted") + } + if err := exclusiveLease(path + ".old"); !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal("old helper lease lost", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".old", path); err != nil { + t.Fatal(err) + } + file, err := restarted.acquireLease() + if err != nil { + t.Fatal("original inode refused", err) + } + file.Close() + if err := os.Remove(strings.TrimSuffix(path, ".lease") + ".lease-identity"); err != nil { + t.Fatal(err) + } + if file, err := restarted.acquireLease(); err == nil { + file.Close() + t.Fatal("missing identity adopted") + } +} + +func TestPythonAndGoUseTheSameDurableLease(t *testing.T) { + root := t.TempDir() + installer, err := filepath.Abs("../../../../../deploy/install") + if err != nil { + t.Fatal(err) + } + script := `import sys +sys.path.insert(0,sys.argv[1]) +from pathlib import Path +import node_generations as g,node_install as i +identity={"installation_id":"test-installation","generation":1,"specification_digest":sys.argv[4]} +with g.collection_lease(Path(sys.argv[2]),1,i,identity,initialize=sys.argv[3]=="init"): pass +` + run := func(mode string) error { + return exec.Command("python3", "-c", script, installer, root, mode, testLeaseIdentity().SpecificationDigest).Run() + } + if err := run("init"); err != nil { + t.Fatal("Python initialization", err) + } + path := filepath.Join(root, "state/node/generations/1.lease") + writeTestFinalGeneration(t, path) + caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} + file, err := caller.acquireLease() + if err != nil { + t.Fatal("Go rejected Python identity", err) + } + if err := run("collect"); err == nil { + file.Close() + t.Fatal("Python collected live Go lease") + } + file.Close() + if err := run("collect"); err != nil { + t.Fatal("settled lease not collectible", err) + } + for _, kind := range []string{"symlink", "hardlink", "fifo"} { + record := strings.TrimSuffix(path, ".lease") + ".lease-identity" + original := record + ".original" + if err := os.Rename(record, original); err != nil { + t.Fatal(err) + } + switch kind { + case "symlink": + err = os.Symlink(original, record) + case "hardlink": + err = os.Link(original, record) + case "fifo": + err = syscall.Mkfifo(record, 0600) + } + if err != nil { + t.Fatal(err) + } + if file, err := caller.acquireLease(); err == nil { + file.Close() + t.Fatal("invalid identity accepted", kind) + } + if err := run("collect"); err == nil { + t.Fatal("Python accepted invalid identity", kind) + } + if err := os.Remove(record); err != nil { + t.Fatal(err) + } + if err := os.Rename(original, record); err != nil { + t.Fatal(err) + } + } +} + +func TestHelperRequiresFinalConfigWithoutAnyUnfinishedJournal(t *testing.T) { + path := filepath.Join(t.TempDir(), "1.lease") + writeTestLease(t, path) + caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} + for _, suffix := range []string{".preparing", ".collecting", ".dropped"} { + marker := strings.TrimSuffix(path, ".lease") + suffix + if err := os.WriteFile(marker, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + if file, err := caller.acquireLease(); err == nil { + file.Close() + t.Fatal("unfinished generation was usable", suffix) + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + } + if err := os.Remove(strings.TrimSuffix(path, ".lease") + ".json"); err != nil { + t.Fatal(err) + } + if file, err := caller.acquireLease(); err == nil { + file.Close() + t.Fatal("unpublished generation was usable") + } + writeTestFinalGeneration(t, path) + file, err := caller.acquireLease() + if err != nil { + t.Fatal(err) + } + file.Close() +} + +func TestOriginalFinalConfigAndWrongGeneration(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "state/node/generations") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "1.lease") + writeTestLease(t, path) + if err := os.Rename(filepath.Join(dir, "1.json"), filepath.Join(root, "provider.json")); err != nil { + t.Fatal(err) + } + caller := &ProcessCaller{LeasePath: path, LeaseIdentity: testLeaseIdentity()} + file, err := caller.acquireLease() + if err != nil { + t.Fatal("initial published config refused", err) + } + file.Close() + raw, _ := json.Marshal(map[string]any{"installation_id": "test-installation", "generation": 2, "provider": "microsandbox", "specification": sandbox.DeploymentSpec{}}) + if err := os.WriteFile(filepath.Join(root, "provider.json"), raw, 0600); err != nil { + t.Fatal(err) + } + if file, err := caller.acquireLease(); err == nil { + file.Close() + t.Fatal("another generation final config accepted") + } +} diff --git a/services/core/internal/sandbox/microsandbox/provider.go b/services/core/internal/sandbox/microsandbox/provider.go new file mode 100644 index 000000000..d6df19ec7 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/provider.go @@ -0,0 +1,222 @@ +package microsandbox + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var ErrUnconfirmed = sandbox.ErrComputeUnconfirmed + +type Provider struct { + config Config + caller Caller +} + +var _ sandbox.SandboxProvider = (*Provider)(nil) +var _ sandbox.CheckpointProvider = (*Provider)(nil) + +func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } +func NewWithCaller(c Config, caller Caller) (*Provider, error) { + if c.Validate() != nil || caller == nil { + return nil, sandbox.ErrInvalid + } + c.Network.Rules = append([]NetworkRule(nil), c.Network.Rules...) + return &Provider{config: c, caller: caller}, nil +} +func (p *Provider) Initial(ctx context.Context, r sandbox.Reference) (Compute, error) { + if err := ctx.Err(); err != nil { + return Compute{}, err + } + if !ValidReference(r) { + return Compute{}, sandbox.ErrInvalid + } + return Compute{Name: Name(p.config, r, 0)}, nil +} +func (p *Provider) call(ctx context.Context, q Request) (Response, error) { + deadline, ok := ctx.Deadline() + if !ok { + return Response{}, sandbox.ErrInvalid + } + q.Version, q.Config, q.Deadline = ProtocolVersion, p.config, deadline + if err := ValidateRequest(q); err != nil { + return Response{}, err + } + out, err := p.caller.Call(ctx, q) + if err != nil { + if q.Operation == "command" { + return out, errors.Join(sandbox.ErrCommandUnconfirmed, err) + } + return out, errors.Join(ErrUnconfirmed, err) + } + if out.Version != ProtocolVersion { + return out, ErrUnconfirmed + } + switch out.ErrorCode { + case "": + return out, nil + case "invalid": + return out, sandbox.ErrInvalid + case "ownership": + return out, sandbox.ErrOwnership + case "exists": + return out, sandbox.ErrExists + case "not_found": + return out, sandbox.ErrNotFound + case "command_unconfirmed": + return out, sandbox.ErrCommandUnconfirmed + case "metrics_unavailable": + return out, runtimeobs.ErrUnavailable + default: + return out, ErrUnconfirmed + } +} +func (p *Provider) state(ctx context.Context, q Request) (State, error) { + out, e := p.call(ctx, q) + if e != nil { + return State{}, e + } + return p.responseState(ctx, q, out) +} + +func (p *Provider) responseState(ctx context.Context, q Request, out Response) (State, error) { + var e error + if out.State == nil || ValidateCompute(p.config, q.Reference, out.State.Compute) != nil || out.State.Compute.ID == "" { + return State{}, ErrUnconfirmed + } + want := q.Compute + if q.Operation == "create" { + want, e = p.Initial(ctx, q.Reference) + if e != nil { + return State{}, e + } + } + if q.Operation == "suspend" { + want = q.Suspend.Source + } + if q.Operation == "resume" { + want = q.Resume.Target + } + got := out.State.Compute + if (got.RestoredFrom == nil) != (want.RestoredFrom == nil) || (want.RestoredFrom != nil && *got.RestoredFrom != *want.RestoredFrom) { + return State{}, sandbox.ErrOwnership + } + if got.Name != want.Name || got.Generation != want.Generation || (want.ID != "" && got.ID != want.ID) { + return State{}, sandbox.ErrOwnership + } + if q.Operation == "suspend" { + s := out.State.Snapshot + if s == nil && q.Suspend.ObserveOnly && out.State.BootstrapComplete && !out.State.SourceStopped && (out.State.Status == "running" || out.State.Status == "paused") { + return *out.State, nil + } + if s == nil || ValidateSnapshot(p.config, q.Reference, *s) != nil || s.OperationID != q.Suspend.OperationID || s.SourceID != want.ID || (!q.Suspend.ObserveOnly && (!out.State.SourceStopped || out.State.Status != "suspended")) { + return State{}, ErrUnconfirmed + } + } + return *out.State, nil +} +func info(r sandbox.Reference, s State) sandbox.Info { + return sandbox.Info{Reference: r, ProviderID: s.Compute.ID, State: s.Status, BootstrapComplete: s.BootstrapComplete} +} +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + q := Request{Operation: "create", Reference: b.Reference, Bootstrap: &b} + out, err := p.call(ctx, q) + settledRejection := errors.Is(err, sandbox.ErrInvalid) && !errors.Is(err, ErrUnconfirmed) && out.CreateSettled + if err != nil && !settledRejection { + return sandbox.Info{Reference: b.Reference}, err + } + s, stateErr := p.responseState(ctx, q, out) + if stateErr != nil { + return sandbox.Info{Reference: b.Reference}, stateErr + } + if settledRejection && (s.Status == "" || s.Status == "absent" || s.BootstrapComplete) { + return sandbox.Info{Reference: b.Reference}, ErrUnconfirmed + } + result := info(b.Reference, s) + if settledRejection { + // Settlement is independent of readiness and survives the original error. + result.CreateSettled, result.BootstrapComplete = true, false + } + return result, err +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + c, e := p.Initial(ctx, r) + if e != nil { + return sandbox.Info{}, e + } + s, e := p.GetCompute(ctx, r, c) + return info(r, s), e +} +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + c, e := p.Initial(ctx, r) + if e != nil { + return e + } + return p.KillCompute(ctx, r, c) +} +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + compute, e := p.Initial(ctx, r) + if e != nil { + return sandbox.CommandResult{}, e + } + return p.RunCommandCompute(ctx, r, compute, c) +} +func (p *Provider) GetCompute(ctx context.Context, r sandbox.Reference, c Compute) (State, error) { + return p.state(ctx, Request{Operation: "inspect", Reference: r, Compute: c}) +} +func (p *Provider) KillCompute(ctx context.Context, r sandbox.Reference, c Compute) error { + _, e := p.call(ctx, Request{Operation: "kill", Reference: r, Compute: c}) + return e +} +func (p *Provider) DeleteSnapshot(ctx context.Context, r sandbox.Reference, s SnapshotIdentity) error { + _, e := p.call(ctx, Request{Operation: "delete_snapshot", Reference: r, Snapshot: &s}) + return e +} +func (p *Provider) Suspend(ctx context.Context, q SuspendRequest) (State, error) { + return p.state(ctx, Request{Operation: "suspend", Reference: q.Reference, Suspend: &q}) +} +func (p *Provider) Resume(ctx context.Context, q ResumeRequest) (State, error) { + return p.state(ctx, Request{Operation: "resume", Reference: q.Reference, Resume: &q}) +} +func (p *Provider) RunCommandCompute(ctx context.Context, r sandbox.Reference, c Compute, command sandbox.Command) (sandbox.CommandResult, error) { + out, e := p.call(ctx, Request{Operation: "command", Reference: r, Compute: c, Command: &command}) + if e != nil { + return sandbox.CommandResult{}, e + } + if out.Command == nil || len(out.Command.Stdout) > MaxOutputBytes || len(out.Command.Stderr) > MaxOutputBytes { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + return *out.Command, nil +} + +// ResumeCompute thaws the exact resident source after an aborted suspension. +// It never starts stopped compute or restores a checkpoint. +func (p *Provider) ResumeCompute(ctx context.Context, r sandbox.Reference, c Compute) (State, error) { + return p.state(ctx, Request{Operation: "resume_compute", Reference: r, Compute: c}) +} + +func (p *Provider) NewCompute(ctx context.Context, r sandbox.Reference, generation uint64, snapshot *SnapshotIdentity) (Compute, error) { + if err := ctx.Err(); err != nil { + return Compute{}, err + } + c := Compute{Generation: generation, Name: Name(p.config, r, generation)} + if snapshot != nil { + value := *snapshot + c.RestoredFrom = &value + } + if e := ValidateCompute(p.config, r, c); e != nil { + return Compute{}, e + } + return c, nil +} + +// Quiescent includes a helper that outlived the caller's canceled context. +func (p *Provider) Quiescent() bool { + v, ok := p.caller.(interface{ Quiescent() bool }) + return ok && v.Quiescent() +} diff --git a/services/core/internal/sandbox/microsandbox/provider_test.go b/services/core/internal/sandbox/microsandbox/provider_test.go new file mode 100644 index 000000000..94bfa9881 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/provider_test.go @@ -0,0 +1,168 @@ +package microsandbox + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +type callerFunc func(context.Context, Request) (Response, error) + +func (f callerFunc) Call(c context.Context, q Request) (Response, error) { return f(c, q) } +func testConfig() Config { + return Config{ + InstallationID: "11111111-1111-4111-8111-111111111111", HelperPath: "/helper", RuntimeHome: "/private/msb", RuntimePath: "/private/bin/msb", FirmwarePath: "/private/lib/libkrunfw.so", + RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), Image: "registry/runtime@sha256:" + strings.Repeat("c", 64), + MemoryMiB: 2048, CPUs: 2, RootDiskMiB: 4096, EnvironmentDiskMiB: 2048, Network: NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny", Rules: []NetworkRule{{Action: "allow", Direction: "egress", Destination: "host"}}}, + } +} +func testRef() sandbox.Reference { + return sandbox.Reference{TenantID: "22222222-2222-4222-8222-222222222222", EnvironmentID: "33333333-3333-4333-8333-333333333333", AllocationID: "44444444-4444-4444-8444-444444444444"} +} +func testSnapshot() SnapshotIdentity { + c, r := testConfig(), testRef() + op := "55555555-5555-4555-8555-555555555555" + return SnapshotIdentity{Reference: SnapshotReference(c, r, op), ID: "snap_exact", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"} +} +func deadline(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + t.Cleanup(cancel) + return ctx +} +func TestRejectsChangedComputeIdentity(t *testing.T) { + c, r := testConfig(), testRef() + p, e := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + got := q.Compute + got.ID = "local:foreign" + return Response{Version: ProtocolVersion, State: &State{Compute: got, Status: "running", BootstrapComplete: true}}, nil + })) + if e != nil { + t.Fatal(e) + } + _, e = p.GetCompute(deadline(t), r, Compute{Name: Name(c, r, 0), ID: "local:4"}) + if !errors.Is(e, sandbox.ErrOwnership) { + t.Fatalf("foreign identity accepted: %v", e) + } +} +func TestRestoreMustRetainExactProvenance(t *testing.T) { + c, r, s := testConfig(), testRef(), testSnapshot() + target := Compute{Generation: 1, Name: Name(c, r, 1), RestoredFrom: &s} + p, _ := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + v := *q.Resume.Target.RestoredFrom + v.ID = "snap_foreign" + got := q.Resume.Target + got.ID = "local:5" + got.RestoredFrom = &v + return Response{Version: ProtocolVersion, State: &State{Compute: got, Status: "running", BootstrapComplete: true}}, nil + })) + _, e := p.Resume(deadline(t), ResumeRequest{Reference: r, OperationID: "66666666-6666-4666-8666-666666666666", Snapshot: s, Target: target}) + if !errors.Is(e, sandbox.ErrOwnership) { + t.Fatalf("different snapshot accepted: %v", e) + } +} +func TestRejectsSnapshotPathBeforeHelper(t *testing.T) { + c, r, s := testConfig(), testRef(), testSnapshot() + s.Reference = "/foreign/checkpoint" + calls := 0 + p, _ := NewWithCaller(c, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) + if e := p.DeleteSnapshot(deadline(t), r, s); !errors.Is(e, sandbox.ErrInvalid) || calls != 0 { + t.Fatalf("e=%v calls=%d", e, calls) + } +} +func TestObserveOnlyPreservesCapturedButResidentState(t *testing.T) { + c, r, s := testConfig(), testRef(), testSnapshot() + source := Compute{Name: Name(c, r, 0), ID: s.SourceID} + p, _ := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + if q.Suspend == nil || !q.Suspend.ObserveOnly { + t.Fatal("observation became mutation") + } + return Response{Version: ProtocolVersion, State: &State{Compute: source, Status: "paused", Snapshot: &s}}, nil + })) + got, e := p.Suspend(deadline(t), SuspendRequest{Reference: r, OperationID: s.OperationID, Source: source, ObserveOnly: true}) + if e != nil || got.SourceStopped || got.Status != "paused" { + t.Fatalf("state=%+v error=%v", got, e) + } +} +func TestCommandUncertaintyAndResultLimits(t *testing.T) { + for _, tc := range []struct { + name string + response Response + failure error + }{ + {"transport", Response{}, errors.New("lost result")}, + {"missing", Response{Version: ProtocolVersion}, nil}, + {"too_large", Response{Version: ProtocolVersion, Command: &sandbox.CommandResult{Stdout: strings.Repeat("x", MaxOutputBytes+1)}}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + p, _ := NewWithCaller(testConfig(), callerFunc(func(context.Context, Request) (Response, error) { return tc.response, tc.failure })) + _, e := p.RunCommand(deadline(t), testRef(), sandbox.Command{Args: []string{"/bin/true"}}) + if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { + t.Fatalf("uncertainty lost: %v", e) + } + }) + } +} +func TestNoDeadlineOrForeignAllocationNeverCallsHelper(t *testing.T) { + calls := 0 + p, _ := NewWithCaller(testConfig(), callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) + _, e := p.GetInfo(context.Background(), testRef()) + if !errors.Is(e, sandbox.ErrInvalid) { + t.Fatal(e) + } + r := testRef() + foreign := r + foreign.EnvironmentID = "77777777-7777-4777-8777-777777777777" + initial, initialErr := p.Initial(deadline(t), r) + if initialErr != nil { + t.Fatal(initialErr) + } + _, e = p.GetCompute(deadline(t), foreign, initial) + if !errors.Is(e, sandbox.ErrInvalid) || calls != 0 { + t.Fatalf("e=%v calls=%d", e, calls) + } +} +func TestHelperEnvironmentDropsCredentialsAndBackendOverrides(t *testing.T) { + got := strings.Join(HelperEnvironment([]string{"HOME=/home/test", "PATH=/bin", "MSB_BACKEND=cloud", "MSB_CONFIG_PATH=/foreign", "MICROSANDBOX_FFI_PATH=/foreign.so", "OPENAI_API_KEY=secret", "HTTPS_PROXY=secret"}, testConfig()), "\n") + for _, bad := range []string{"secret", "cloud", "foreign"} { + if strings.Contains(got, bad) { + t.Fatalf("ambient override retained: %s", got) + } + } + if !strings.Contains(got, "MSB_BACKEND=local") { + t.Fatal("missing local backend") + } +} + +func TestMissingSnapshotObservationAllowsOnlyIntactSourceRollback(t *testing.T) { + for _, status := range []string{"running", "paused", "stopped", "draining", "unknown"} { + t.Run(status, func(t *testing.T) { + c, r, s := testConfig(), testRef(), testSnapshot() + source := Compute{Name: Name(c, r, 0), ID: s.SourceID} + p, _ := NewWithCaller(c, callerFunc(func(context.Context, Request) (Response, error) { + return Response{Version: ProtocolVersion, State: &State{Compute: source, Status: status, BootstrapComplete: true}}, nil + })) + _, e := p.Suspend(deadline(t), SuspendRequest{Reference: r, OperationID: s.OperationID, Source: source, ObserveOnly: true}) + if status == "running" || status == "paused" { + if e != nil { + t.Fatal(e) + } + } else if e == nil { + t.Fatal("unrecoverable source accepted") + } + }) + } +} +func TestImageMustUseCompletePinnedOCIManifestDigest(t *testing.T) { + for _, image := range []string{"image:latest", "image@sha256:garbage", "sha256:" + strings.Repeat("a", 64), "image@sha256:" + strings.Repeat("A", 64)} { + c := testConfig() + c.Image = image + if c.Validate() == nil { + t.Fatalf("unqualified image admitted: %s", image) + } + } +} diff --git a/services/core/internal/sandbox/microsandbox/resources.go b/services/core/internal/sandbox/microsandbox/resources.go new file mode 100644 index 000000000..77897ed65 --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/resources.go @@ -0,0 +1,76 @@ +package microsandbox + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var _ runtimeobs.Source = (*Provider)(nil) + +func (*Provider) ObservationProviderType() string { return "microsandbox" } + +// Observe reads one point-in-time native metrics snapshot through the existing +// one-shot helper. The persisted compute receipt selects the exact generation; +// browser input and provider display names never select a sandbox. +func (p *Provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { + if target.Mode != runtimeobs.ModeManaged || target.Instance.AllocationID == "" { + return runtimeobs.Sample{}, sandbox.ErrInvalid + } + if target.Instance.ProviderKey != p.config.InstallationID { + return runtimeobs.Sample{}, sandbox.ErrOwnership + } + reference := sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} + if target.Instance.ComputePhase == "suspended" { + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + } + var state struct { + Current Compute `json:"current"` + } + if json.Unmarshal(target.Instance.ProviderState, &state) != nil || state.Current.ID == "" { + // Suspension-disabled allocations predate durable compute receipts. A + // deterministic name is not an incarnation fence, so do not sample it. + if target.Instance.ComputePhase == "disabled" { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + return runtimeobs.Sample{}, sandbox.ErrOwnership + } + if ValidateCompute(p.config, reference, state.Current) != nil { + return runtimeobs.Sample{}, sandbox.ErrOwnership + } + out, err := p.call(ctx, Request{Operation: "metrics", Reference: reference, Compute: state.Current}) + if errors.Is(err, sandbox.ErrNotFound) { + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + } + if err != nil { + return runtimeobs.Sample{}, err + } + if out.Metrics == nil { + return runtimeobs.Sample{}, ErrUnconfirmed + } + return sampleFromMetrics(p.config, *out.Metrics) +} + +func sampleFromMetrics(config Config, metrics Metrics) (runtimeobs.Sample, error) { + if metrics.ObservedAt.IsZero() || metrics.Uptime < 0 || metrics.MemoryLimitBytes == 0 || config.CPUs == 0 { + return runtimeobs.Sample{}, ErrUnconfirmed + } + // Both values come from one native registry sample at millisecond precision. + // Helper wall time and the SDK's rounded uptime cannot establish this fence. + startedAt := metrics.ObservedAt.Add(-metrics.Uptime) + if startedAt.Unix() < 0 || startedAt.After(metrics.ObservedAt) { + return runtimeobs.Sample{}, ErrUnconfirmed + } + cpuUsage := float64(metrics.VCPUTimeNs) / float64(time.Second) + cpuCapacity := float64(config.CPUs) + memoryUsage, memoryLimit := metrics.MemoryBytes, metrics.MemoryLimitBytes + return runtimeobs.Sample{ + ObservedAt: metrics.ObservedAt, StartedAt: &startedAt, + CPUUsageSecondsTotal: &cpuUsage, CPUCapacityCores: &cpuCapacity, + MemoryUsageBytes: &memoryUsage, MemoryLimitBytes: &memoryLimit, + }, nil +} diff --git a/services/core/internal/sandbox/microsandbox/resources_test.go b/services/core/internal/sandbox/microsandbox/resources_test.go new file mode 100644 index 000000000..c52eaf2ec --- /dev/null +++ b/services/core/internal/sandbox/microsandbox/resources_test.go @@ -0,0 +1,171 @@ +package microsandbox + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func observationTarget(t *testing.T, compute Compute) runtimeobs.Target { + t.Helper() + state, err := json.Marshal(struct { + Current Compute `json:"current"` + }{Current: compute}) + if err != nil { + t.Fatal(err) + } + r := testRef() + return runtimeobs.Target{ + TenantID: r.TenantID, SessionID: "55555555-5555-4555-8555-555555555555", EnvironmentID: r.EnvironmentID, Mode: runtimeobs.ModeManaged, + Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: testConfig().InstallationID, AllocationState: "running", ComputePhase: "running", ProviderState: state}, + } +} + +func TestObserveUsesPersistedExactComputeAndNormalizesMetrics(t *testing.T) { + config, reference := testConfig(), testRef() + compute := Compute{Name: Name(config, reference, 3), ID: "local:current", Generation: 3, RestoredFrom: func() *SnapshotIdentity { value := testSnapshot(); return &value }()} + // The snapshot fixture belongs to generation zero and is valid provenance for generation three. + observed := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { + if request.Operation != "metrics" || request.Reference != reference || request.Compute.ID != compute.ID || request.Compute.Generation != compute.Generation { + t.Fatalf("unexpected metrics request: %+v", request) + } + return Response{Version: ProtocolVersion, Metrics: &Metrics{ + ObservedAt: observed, Uptime: 5 * time.Minute, VCPUTimeNs: 2_500_000_000, + MemoryBytes: 1024, MemoryLimitBytes: 2 * 1024 * 1024, + }}, nil + })) + if err != nil { + t.Fatal(err) + } + sample, err := provider.Observe(deadline(t), observationTarget(t, compute)) + if err != nil { + t.Fatal(err) + } + if sample.StartedAt == nil || !sample.StartedAt.Equal(observed.Add(-5*time.Minute)) || sample.CPUUsageSecondsTotal == nil || *sample.CPUUsageSecondsTotal != 2.5 || sample.CPUCapacityCores == nil || *sample.CPUCapacityCores != 2 || sample.MemoryUsageBytes == nil || *sample.MemoryUsageBytes != 1024 || sample.MemoryLimitBytes == nil || *sample.MemoryLimitBytes != 2*1024*1024 { + t.Fatalf("bad normalized sample: %+v", sample) + } +} + +func TestObserveRejectsForeignOrMissingComputeBeforeHelper(t *testing.T) { + config, reference := testConfig(), testRef() + compute := Compute{Name: Name(config, reference, 0), ID: "local:current"} + calls := 0 + provider, _ := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil })) + + foreign := observationTarget(t, compute) + foreign.Instance.ProviderKey = "66666666-6666-4666-8666-666666666666" + if _, err := provider.Observe(deadline(t), foreign); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("foreign installation accepted: %v", err) + } + missing := observationTarget(t, compute) + missing.Instance.ProviderState = json.RawMessage(`{}`) + if _, err := provider.Observe(deadline(t), missing); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("missing compute accepted: %v", err) + } + if calls != 0 { + t.Fatalf("invalid identity reached helper: %d calls", calls) + } +} + +func TestObserveWithoutExactReceiptAndSuspendedDoesNotWake(t *testing.T) { + config := testConfig() + calls := 0 + provider, _ := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { + calls++ + return Response{}, nil + })) + target := observationTarget(t, Compute{Name: Name(config, testRef(), 0), ID: "unused"}) + target.Instance.ComputePhase, target.Instance.ProviderState = "disabled", nil + if _, err := provider.Observe(deadline(t), target); !errors.Is(err, runtimeobs.ErrUnavailable) { + t.Fatalf("receipt-less compute was sampled: %v", err) + } + target.Instance.ComputePhase = "suspended" + if _, err := provider.Observe(deadline(t), target); !errors.Is(err, runtimeobs.ErrNotRunning) { + t.Fatalf("suspended compute was not unavailable: %v", err) + } + if calls != 0 { + t.Fatalf("unfenced compute reached helper: %d calls", calls) + } +} + +func TestObserveMapsStoppedAndMetricsUnavailable(t *testing.T) { + config, reference := testConfig(), testRef() + compute := Compute{Name: Name(config, reference, 0), ID: "local:current"} + for _, test := range []struct { + name string + response Response + want error + }{ + {name: "stopped", response: Response{Version: ProtocolVersion, ErrorCode: "not_found"}, want: runtimeobs.ErrNotRunning}, + {name: "metrics unavailable", response: Response{Version: ProtocolVersion, ErrorCode: "metrics_unavailable"}, want: runtimeobs.ErrUnavailable}, + } { + t.Run(test.name, func(t *testing.T) { + provider, _ := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { return test.response, nil })) + if _, err := provider.Observe(deadline(t), observationTarget(t, compute)); !errors.Is(err, test.want) { + t.Fatalf("error=%v want=%v", err, test.want) + } + }) + } +} + +func TestSampleFromMetricsRejectsInvalidTimingAndLimit(t *testing.T) { + config := testConfig() + for _, metrics := range []Metrics{ + {}, + {ObservedAt: time.Unix(1, 0), Uptime: -time.Second, MemoryLimitBytes: 1}, + {ObservedAt: time.Unix(1, 0), Uptime: 2 * time.Second, MemoryLimitBytes: 1}, + {ObservedAt: time.Unix(1, 0), MemoryLimitBytes: 0}, + } { + if _, err := sampleFromMetrics(config, metrics); err == nil { + t.Fatalf("invalid metrics accepted: %+v", metrics) + } + } +} + +func TestObserveKeepsIncarnationAcrossPollsAndCoreRestart(t *testing.T) { + config, reference := testConfig(), testRef() + compute := Compute{Name: Name(config, reference, 0), ID: "local:first"} + startedAt := time.Date(2026, 9, 22, 12, 0, 0, 122000000, time.UTC) + var previous runtimeobs.Sample + for index, uptime := range []time.Duration{300001 * time.Millisecond, 301877 * time.Millisecond, time.Millisecond} { + currentStart := startedAt + cpu := uint64(2_500_000_000 + index*1_000_000_000) + if index == 2 { + compute = Compute{Name: Name(config, reference, 1), ID: "local:restored", Generation: 1, RestoredFrom: func() *SnapshotIdentity { s := testSnapshot(); return &s }()} + currentStart = startedAt.Add(5 * time.Minute) + cpu = 1_000_000 + } + // Each poll uses a fresh Core provider, with no process-local time cache. + provider, err := NewWithCaller(config, callerFunc(func(_ context.Context, request Request) (Response, error) { + if request.Compute.ID != compute.ID || request.Compute.Generation != compute.Generation { + t.Fatalf("wrong compute: %+v", request.Compute) + } + return Response{Version: ProtocolVersion, Metrics: &Metrics{ObservedAt: currentStart.Add(uptime), Uptime: uptime, VCPUTimeNs: cpu, MemoryLimitBytes: 8192}}, nil + })) + if err != nil { + t.Fatal(err) + } + sample, err := provider.Observe(deadline(t), observationTarget(t, compute)) + if err != nil { + t.Fatal(err) + } + if sample.StartedAt == nil || !sample.StartedAt.Equal(currentStart) { + t.Fatalf("wrong start: %+v", sample) + } + if index == 1 { + if !sample.StartedAt.Equal(*previous.StartedAt) || *sample.CPUUsageSecondsTotal-*previous.CPUUsageSecondsTotal != 1 { + t.Fatal("repeated polls lost the CPU delta") + } + } + if index == 2 && sample.StartedAt.Equal(*previous.StartedAt) { + t.Fatal("restored compute reused the source incarnation") + } + previous = sample + } +} diff --git a/services/agents-api/internal/sandbox/microsandbox/types.go b/services/core/internal/sandbox/microsandbox/types.go similarity index 97% rename from services/agents-api/internal/sandbox/microsandbox/types.go rename to services/core/internal/sandbox/microsandbox/types.go index ea1efdad1..12ddc1f69 100644 --- a/services/agents-api/internal/sandbox/microsandbox/types.go +++ b/services/core/internal/sandbox/microsandbox/types.go @@ -6,7 +6,7 @@ import ( "context" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) const ProtocolVersion = 2 diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go similarity index 98% rename from services/agents-api/internal/sandbox/node/agent.go rename to services/core/internal/sandbox/node/agent.go index 4883979c3..3d31e488b 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -3,7 +3,7 @@ package node import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "math/rand/v2" "net/http" "net/url" @@ -12,8 +12,8 @@ import ( "sync/atomic" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/sandbox/node/capacity_test.go b/services/core/internal/sandbox/node/capacity_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/capacity_test.go rename to services/core/internal/sandbox/node/capacity_test.go diff --git a/services/core/internal/sandbox/node/connection_test.go b/services/core/internal/sandbox/node/connection_test.go new file mode 100644 index 000000000..ede51f5b2 --- /dev/null +++ b/services/core/internal/sandbox/node/connection_test.go @@ -0,0 +1,288 @@ +package node + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/gorilla/websocket" +) + +func nodeEndpoint(origin, id string) string { + return "ws" + strings.TrimPrefix(origin, "http") + "?node_id=" + id +} +func assertDuplicateRejected(t *testing.T, origin, id, credential string) { + t.Helper() + conn, response, err := websocket.DefaultDialer.Dial(nodeEndpoint(origin, id), http.Header{"Authorization": []string{"Bearer " + credential}}) + if conn != nil { + conn.Close() + } + if response != nil { + defer response.Body.Close() + } + if err == nil || response == nil || response.StatusCode != http.StatusConflict { + t.Fatalf("duplicate connection was not rejected with 409: response=%v err=%v", responseStatus(response), err) + } +} +func responseStatus(r *http.Response) int { + if r == nil { + return 0 + } + return r.StatusCode +} +func reservationReleased(h *Hub, id string) bool { + h.mu.Lock() + defer h.mu.Unlock() + _, held := h.reservations[id] + return !held +} +func connectRawNode(t *testing.T, origin string, id Identity) *websocket.Conn { + t.Helper() + conn, _, err := websocket.DefaultDialer.Dial(nodeEndpoint(origin, id.NodeID), http.Header{"Authorization": []string{"Bearer test"}}) + if err != nil { + t.Fatal(err) + } + if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { + conn.Close() + t.Fatal(err) + } + if _, err = readFrame(conn); err != nil { + conn.Close() + t.Fatal(err) + } + return conn +} + +func TestHubReservesIdentityAcrossConcurrentOpeningHandshakes(t *testing.T) { + id := identity() + var connected atomic.Int32 + hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Connected: func(context.Context, Identity, string, uint64) error { connected.Add(1); return nil }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + type result struct { + conn *websocket.Conn + status int + err error + } + results := make(chan result, 2) + start := make(chan struct{}) + for range 2 { + go func() { + <-start + conn, response, err := websocket.DefaultDialer.Dial(nodeEndpoint(server.URL, id.NodeID), http.Header{"Authorization": []string{"Bearer test"}}) + status := responseStatus(response) + if response != nil { + response.Body.Close() + } + results <- result{conn, status, err} + }() + } + close(start) + var winner *websocket.Conn + accepted, rejected := 0, 0 + for range 2 { + r := <-results + if r.err == nil { + accepted++ + winner = r.conn + } else if r.status == http.StatusConflict { + rejected++ + } else { + t.Errorf("unexpected handshake result: status=%d err=%v", r.status, r.err) + } + } + if winner != nil { + defer winner.Close() + } + if accepted != 1 || rejected != 1 || connected.Load() != 0 { + t.Fatalf("opening identity was not exclusive: accepted=%d rejected=%d callbacks=%d", accepted, rejected, connected.Load()) + } + // No hello was sent: even an unfinished handshake owns the reservation, and + // its failure must release it without touching persisted node presence. + assertDuplicateRejected(t, server.URL, id.NodeID, "test") + winner.Close() + wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) + conn := connectRawNode(t, server.URL, id) + defer conn.Close() + wait(t, func() bool { return hub.Online(id.NodeID) }) + if connected.Load() != 1 { + t.Fatal("failed handshake changed node presence") + } +} + +func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { + id := identity() + var credential string + var authenticated, connected atomic.Int32 + duplicateAttempt := make(chan struct{}, 1) + hub := NewHub(HubOptions{Authenticate: func(ctx context.Context, node, token string) (Identity, error) { + if node != id.NodeID || token != credential { + return Identity{}, ErrAuthentication + } + if authenticated.Add(1) > 1 { + select { + case duplicateAttempt <- struct{}{}: + default: + } + } + return id, nil + }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Connected: func(context.Context, Identity, string, uint64) error { connected.Add(1); return nil }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + credential = stored.Credential + duplicateDir := stateDir(t) + if err = writeIdentity(duplicateDir, stored); err != nil { + t.Fatal(err) + } + original := &fakeProvider{started: make(chan struct{}), release: make(chan struct{})} + duplicate := &fakeProvider{} + var release sync.Once + defer release.Do(func() { close(original.release) }) + start := func(dir string, p sandbox.SandboxProvider) (context.CancelFunc, chan error) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: credential, Provider: p, Probe: probe}) + }() + return cancel, done + } + stopOriginal, originalDone := start(dir, original) + defer func() { + release.Do(func() { close(original.release) }) + stopOriginal() + if err := <-originalDone; err != nil { + t.Error(err) + } + }() + wait(t, func() bool { return hub.Online(id.NodeID) }) + hub.mu.Lock() + first := hub.peers[id.NodeID] + hub.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + proxy := hub.Proxy(id.NodeID, "docker", 1) + r := reference() + created := make(chan error, 1) + go func() { _, err := proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); created <- err }() + <-original.started + stopDuplicate, duplicateDone := start(duplicateDir, duplicate) + defer func() { + stopDuplicate() + if err := <-duplicateDone; err != nil { + t.Error(err) + } + }() + select { + case <-duplicateAttempt: + case <-time.After(time.Second): + t.Fatal("duplicate did not attempt connection") + } + for range 3 { + assertDuplicateRejected(t, server.URL, id.NodeID, credential) + } + hub.mu.Lock() + retained := hub.peers[id.NodeID] == first + hub.mu.Unlock() + if !retained || connected.Load() != 1 { + t.Fatal("duplicate replaced original connection or persisted its presence") + } + release.Do(func() { close(original.release) }) + if err = <-created; err != nil { + t.Fatal(err) + } + if _, err = proxy.GetInfo(ctx, r); err != nil { + t.Fatal(err) + } + original.mu.Lock() + creates, reads := original.creates, original.reads + original.mu.Unlock() + duplicate.mu.Lock() + duplicateEffects := duplicate.creates + duplicate.reads + duplicate.kills + duplicate.mu.Unlock() + if creates != 1 || reads != 1 || duplicateEffects != 0 { + t.Fatalf("work moved/replayed: creates=%d reads=%d duplicate=%d", creates, reads, duplicateEffects) + } +} + +func TestReservationRemainsUntilFencedDisconnectFinishes(t *testing.T) { + id := identity() + retiring := make(chan struct{}) + finish := make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(finish) }) + var mu sync.Mutex + active := "" + connections := 0 + var callbackErr error + hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 9, nil }, Connected: func(ctx context.Context, id Identity, connection string, epoch uint64) error { + mu.Lock() + defer mu.Unlock() + if epoch != 9 { + callbackErr = errors.New("incorrect connect epoch") + } + active = connection + connections++ + return nil + }, Disconnected: func(ctx context.Context, id Identity, connection string, epoch uint64) { + mu.Lock() + first := connections == 1 + mu.Unlock() + if first { + close(retiring) + <-finish + } + mu.Lock() + defer mu.Unlock() + if epoch != 9 { + callbackErr = errors.New("incorrect disconnect epoch") + } + if active == connection { + active = "" + } + }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + first := connectRawNode(t, server.URL, id) + wait(t, func() bool { return hub.Online(id.NodeID) }) + first.Close() + select { + case <-retiring: + case <-time.After(time.Second): + t.Fatal("disconnect callback did not start") + } + assertDuplicateRejected(t, server.URL, id.NodeID, "test") + mu.Lock() + count := connections + mu.Unlock() + if count != 1 { + t.Fatal("new connection admitted before disconnect settled") + } + once.Do(func() { close(finish) }) + wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) + next := connectRawNode(t, server.URL, id) + defer next.Close() + wait(t, func() bool { return hub.Online(id.NodeID) }) + hub.mu.Lock() + current := hub.peers[id.NodeID].id + hub.mu.Unlock() + mu.Lock() + defer mu.Unlock() + if active != current || connections != 2 || callbackErr != nil { + t.Fatalf("stale disconnect cleared current presence: count=%d error=%v", connections, callbackErr) + } +} diff --git a/services/core/internal/sandbox/node/creation_settlement_test.go b/services/core/internal/sandbox/node/creation_settlement_test.go new file mode 100644 index 000000000..8f04fe7b0 --- /dev/null +++ b/services/core/internal/sandbox/node/creation_settlement_test.go @@ -0,0 +1,93 @@ +package node + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +type settledProvider struct { + fakeProvider + info sandbox.Info + err error +} + +func (p *settledProvider) Create(context.Context, sandbox.Bootstrap) (sandbox.Info, error) { + return p.info, p.err +} +func (p *settledProvider) GetInfo(context.Context, sandbox.Reference) (sandbox.Info, error) { + return p.info, p.err +} + +func TestNodeCarriesCreationSettlementWithoutConvertingFailureToSuccess(t *testing.T) { + ref := reference() + for _, test := range []struct { + name string + info sandbox.Info + err error + keep bool + }{ + {"rejected before bootstrap", sandbox.Info{Reference: ref, ProviderID: "owned", CreateSettled: true}, sandbox.ErrInvalid, true}, + {"confirmed absent", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true}, sandbox.ErrInvalid, true}, + {"observed absent", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true}, nil, true}, + {"unsettled error", sandbox.Info{Reference: ref, ProviderID: "owned"}, sandbox.ErrInvalid, false}, + {"foreign settlement", sandbox.Info{Reference: reference(), ProviderID: "foreign", CreateSettled: true}, sandbox.ErrInvalid, false}, + {"absent but ready", sandbox.Info{Reference: ref, State: "absent", CreateSettled: true, BootstrapComplete: true}, sandbox.ErrInvalid, false}, + {"missing compute identity", sandbox.Info{Reference: ref, CreateSettled: true}, sandbox.ErrInvalid, false}, + } { + t.Run(test.name, func(t *testing.T) { + id := identity() + hub := NewHub(HubOptions{ + Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, + OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, + }) + defer hub.Close() + server := httptest.NewServer(hub) + defer server.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, + Provider: &settledProvider{info: test.info, err: test.err}, Probe: probe}) + }() + defer func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(5 * time.Second): + t.Error("node did not stop") + } + }() + wait(t, func() bool { return hub.Online(id.NodeID) }) + proxy := hub.Proxy(id.NodeID, "docker", 1) + for _, operation := range []func(context.Context) (sandbox.Info, error){ + func(ctx context.Context) (sandbox.Info, error) { + return proxy.Create(ctx, sandbox.Bootstrap{Reference: ref}) + }, + func(ctx context.Context) (sandbox.Info, error) { return proxy.GetInfo(ctx, ref) }, + } { + call, stop := context.WithTimeout(ctx, 3*time.Second) + info, err := operation(call) + stop() + if !errors.Is(err, test.err) { + t.Fatalf("operation error changed: %v", err) + } + if test.keep && info != test.info || !test.keep && info != (sandbox.Info{}) { + t.Fatalf("incorrect settlement forwarding: %+v", info) + } + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/node/docker_live_test.go b/services/core/internal/sandbox/node/docker_live_test.go similarity index 97% rename from services/agents-api/internal/sandbox/node/docker_live_test.go rename to services/core/internal/sandbox/node/docker_live_test.go index 3673945b6..d9f0de5f9 100644 --- a/services/agents-api/internal/sandbox/node/docker_live_test.go +++ b/services/core/internal/sandbox/node/docker_live_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + sandboxdocker "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" "github.com/google/uuid" "github.com/moby/moby/client" ) diff --git a/services/agents-api/internal/sandbox/node/enrollment.go b/services/core/internal/sandbox/node/enrollment.go similarity index 100% rename from services/agents-api/internal/sandbox/node/enrollment.go rename to services/core/internal/sandbox/node/enrollment.go diff --git a/services/agents-api/internal/sandbox/node/enrollment_status_test.go b/services/core/internal/sandbox/node/enrollment_status_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/enrollment_status_test.go rename to services/core/internal/sandbox/node/enrollment_status_test.go diff --git a/services/agents-api/internal/sandbox/node/generation_agent.go b/services/core/internal/sandbox/node/generation_agent.go similarity index 96% rename from services/agents-api/internal/sandbox/node/generation_agent.go rename to services/core/internal/sandbox/node/generation_agent.go index be3021445..26bb810ed 100644 --- a/services/agents-api/internal/sandbox/node/generation_agent.go +++ b/services/core/internal/sandbox/node/generation_agent.go @@ -3,7 +3,7 @@ package node import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/sandbox/node/generation_connection_test.go b/services/core/internal/sandbox/node/generation_connection_test.go similarity index 97% rename from services/agents-api/internal/sandbox/node/generation_connection_test.go rename to services/core/internal/sandbox/node/generation_connection_test.go index 0879d90d0..f440ba333 100644 --- a/services/agents-api/internal/sandbox/node/generation_connection_test.go +++ b/services/core/internal/sandbox/node/generation_connection_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func TestGenerationWireRoutesOldOwnershipAndCurrentTargetSeparately(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/node/generation_json.go b/services/core/internal/sandbox/node/generation_json.go similarity index 98% rename from services/agents-api/internal/sandbox/node/generation_json.go rename to services/core/internal/sandbox/node/generation_json.go index a3cda6163..c57e0ae07 100644 --- a/services/agents-api/internal/sandbox/node/generation_json.go +++ b/services/core/internal/sandbox/node/generation_json.go @@ -6,7 +6,7 @@ import ( "io" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) // Decode control objects before trusting Go's struct decoder: that decoder diff --git a/services/agents-api/internal/sandbox/node/generation_json_test.go b/services/core/internal/sandbox/node/generation_json_test.go similarity index 98% rename from services/agents-api/internal/sandbox/node/generation_json_test.go rename to services/core/internal/sandbox/node/generation_json_test.go index aaacee1a9..08620c8ee 100644 --- a/services/agents-api/internal/sandbox/node/generation_json_test.go +++ b/services/core/internal/sandbox/node/generation_json_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/sandbox/node/generation_wire.go b/services/core/internal/sandbox/node/generation_wire.go similarity index 98% rename from services/agents-api/internal/sandbox/node/generation_wire.go rename to services/core/internal/sandbox/node/generation_wire.go index d37cbc34e..11884877b 100644 --- a/services/agents-api/internal/sandbox/node/generation_wire.go +++ b/services/core/internal/sandbox/node/generation_wire.go @@ -4,7 +4,7 @@ import ( "encoding/hex" "math" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) // A control reply grants only its correlated sparse entries. There is no diff --git a/services/core/internal/sandbox/node/generations.go b/services/core/internal/sandbox/node/generations.go new file mode 100644 index 000000000..95613b674 --- /dev/null +++ b/services/core/internal/sandbox/node/generations.go @@ -0,0 +1,387 @@ +package node + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +type GenerationProvider struct { + Generation uint64 + SpecificationDigest string + Provider sandbox.SandboxProvider + Probe func(context.Context) error + Close func() +} + +type GenerationManagerOptions struct { + Initial []GenerationProvider + Recover []sandbox.GenerationReference + Collect []sandbox.GenerationReference + Prepare func(context.Context, uint64, string) (GenerationProvider, error) + Remove func(context.Context, GenerationProvider) error +} + +type localGeneration struct { + value GenerationProvider + state, diagnostic string + refs int + retryAt time.Time + failures int + removing bool + repairing, preparing bool + collecting bool +} + +// GenerationManager owns local providers, preparation and all queued/in-flight +// references. Wire metadata is sparse; only correlated Core grants call Drop. +type GenerationManager struct { + mu sync.Mutex + values map[uint64]*localGeneration + target sandbox.NodeDeployment + statusCursor, probeCursor, recoveryCursor uint64 + priorityProbe uint64 + options GenerationManagerOptions + ctx context.Context + cancel context.CancelFunc + preparingCancel context.CancelFunc + wake chan struct{} + workers sync.WaitGroup +} + +func NewGenerationManager(ctx context.Context, options GenerationManagerOptions) (*GenerationManager, error) { + if options.Prepare == nil || options.Remove == nil { + return nil, sandbox.ErrInvalid + } + owned, cancel := context.WithCancel(ctx) + m := &GenerationManager{values: map[uint64]*localGeneration{}, options: options, ctx: owned, cancel: cancel, wake: make(chan struct{}, 1)} + for _, v := range options.Initial { + if !validGeneration(v.Generation) || !validSpecificationDigest(v.SpecificationDigest) || sandbox.ValidateProvider(v.Provider) != nil || v.Probe == nil || m.values[v.Generation] != nil { + cancel() + return nil, sandbox.ErrInvalid + } + m.values[v.Generation] = &localGeneration{value: v, state: "preparing"} + } + for _, ref := range options.Recover { + if !validGeneration(ref.Generation) || !validSpecificationDigest(ref.SpecificationDigest) || m.values[ref.Generation] != nil { + cancel() + return nil, sandbox.ErrInvalid + } + m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, state: "failed", diagnostic: sandbox.NodeProviderUnavailable, repairing: true} + } + for _, ref := range options.Collect { + if !validGeneration(ref.Generation) || !validSpecificationDigest(ref.SpecificationDigest) || m.values[ref.Generation] != nil { + cancel() + return nil, sandbox.ErrInvalid + } + m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, collecting: true} + } + m.workers.Add(2) + go func() { defer m.workers.Done(); m.prepareLoop() }() + go func() { defer m.workers.Done(); m.probeLoop() }() + return m, nil +} + +func (m *GenerationManager) Close() { + m.cancel() + m.workers.Wait() + m.mu.Lock() + defer m.mu.Unlock() + for _, g := range m.values { + if g.value.Close != nil { + g.value.Close() + } + } +} + +func (m *GenerationManager) Deployment(value sandbox.NodeDeployment) error { + m.mu.Lock() + defer m.mu.Unlock() + if !validGeneration(value.Generation) || !validSpecificationDigest(value.SpecificationDigest) || value.ServingGeneration != nil && (!validGeneration(*value.ServingGeneration) || *value.ServingGeneration > value.Generation) { + return sandbox.ErrOwnership + } + if value.Generation < m.target.Generation { + return nil + } + if g := m.values[value.Generation]; g != nil && (g.value.SpecificationDigest != value.SpecificationDigest || g.collecting) { + return sandbox.ErrOwnership + } + if value.ServingGeneration != nil { + if g := m.values[*value.ServingGeneration]; g != nil && g.collecting { + return sandbox.ErrOwnership + } + } + if value.Generation == m.target.Generation && value.SpecificationDigest != m.target.SpecificationDigest { + return sandbox.ErrOwnership + } + if value.Generation != m.target.Generation && m.preparingCancel != nil { + m.preparingCancel() + } + m.target = value + if g := m.values[value.Generation]; g == nil { + m.values[value.Generation] = &localGeneration{value: GenerationProvider{Generation: value.Generation, SpecificationDigest: value.SpecificationDigest}, state: "preparing"} + } + select { + case m.wake <- struct{}{}: + default: + } + return nil +} + +func (m *GenerationManager) orderedLocked(after uint64) []uint64 { + keys := make([]uint64, 0, len(m.values)) + for g, v := range m.values { + if !v.removing { + keys = append(keys, g) + } + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + pivot := sort.Search(len(keys), func(i int) bool { return keys[i] > after }) + return append(keys[pivot:], keys[:pivot]...) +} + +func (m *GenerationManager) Statuses() []sandbox.GenerationStatus { + m.mu.Lock() + defer m.mu.Unlock() + keys := []uint64{m.target.Generation} + if m.target.ServingGeneration != nil { + keys = append(keys, *m.target.ServingGeneration) + } + keys = append(keys, m.orderedLocked(m.statusCursor)...) + result := make([]sandbox.GenerationStatus, 0, 8) + seen := map[uint64]bool{} + for _, key := range keys { + g := m.values[key] + if g == nil || g.removing || g.collecting || seen[key] { + continue + } + seen[key] = true + result = append(result, sandbox.GenerationStatus{Generation: key, SpecificationDigest: g.value.SpecificationDigest, State: g.state, Diagnostic: g.diagnostic}) + if key != m.target.Generation && (m.target.ServingGeneration == nil || key != *m.target.ServingGeneration) { + m.statusCursor = key + } + if len(result) == 8 { + break + } + } + return result +} + +func (m *GenerationManager) Retained(after uint64) []sandbox.GenerationReference { + m.mu.Lock() + defer m.mu.Unlock() + keys := m.orderedLocked(after) + result := make([]sandbox.GenerationReference, 0, 8) + for _, key := range keys { + g := m.values[key] + result = append(result, sandbox.GenerationReference{Generation: key, SpecificationDigest: g.value.SpecificationDigest}) + if len(result) == 8 { + break + } + } + return result +} + +// Acquire is called before queue admission, so queued work also prevents GC. +func (m *GenerationManager) Acquire(generation uint64) (sandbox.SandboxProvider, bool, func(), error) { + m.mu.Lock() + defer m.mu.Unlock() + g := m.values[generation] + if g == nil || g.removing || g.collecting || g.preparing || g.value.Provider == nil { + return nil, false, nil, ErrUnavailable + } + g.refs++ + var once sync.Once + release := func() { once.Do(func() { m.mu.Lock(); g.refs--; m.mu.Unlock() }) } + return g.value.Provider, g.state == "ready", release, nil +} + +func (m *GenerationManager) Drop(ctx context.Context, grant sandbox.GenerationRetention) error { + if err := ctx.Err(); err != nil { + return err + } + if grant.Keep { + return nil + } + m.mu.Lock() + g := m.values[grant.Generation] + if g == nil { + m.mu.Unlock() + return nil + } + if g.value.SpecificationDigest != grant.SpecificationDigest { + m.mu.Unlock() + return sandbox.ErrOwnership + } + quiet := true + if provider, ok := g.value.Provider.(interface{ Quiescent() bool }); ok { + quiet = provider.Quiescent() + } + if !quiet || g.refs != 0 || g.removing || m.target.Generation == grant.Generation || m.target.ServingGeneration != nil && *m.target.ServingGeneration == grant.Generation { + m.mu.Unlock() + return ErrUnavailable + } + g.collecting = true + g.removing = true + m.mu.Unlock() + err := m.options.Remove(ctx, g.value) + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + g.removing = false + return err + } + if g.value.Close != nil { + g.value.Close() + } + delete(m.values, grant.Generation) + return nil +} + +func (m *GenerationManager) prepareLoop() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-m.ctx.Done(): + return + case <-m.wake: + case <-ticker.C: + } + m.mu.Lock() + var g *localGeneration + if m.target.Generation != 0 { + keys := []uint64{m.target.Generation} + if m.target.ServingGeneration != nil { + keys = append(keys, *m.target.ServingGeneration) + } + keys = append(keys, m.orderedLocked(m.recoveryCursor)...) + for _, key := range keys { + candidate := m.values[key] + if candidate == nil || candidate.removing || candidate.collecting || candidate.preparing || candidate.refs != 0 || candidate.value.Provider != nil && !candidate.repairing || time.Now().Before(candidate.retryAt) { + continue + } + if provider, ok := candidate.value.Provider.(interface{ Quiescent() bool }); ok && !provider.Quiescent() { + continue + } + g = candidate + m.recoveryCursor = key + break + } + } + if g == nil { + m.mu.Unlock() + continue + } + ctx, cancel := context.WithTimeout(m.ctx, 30*time.Minute) + m.preparingCancel = cancel + g.preparing = true + g.refs++ + g.state = "preparing" + g.diagnostic = "" + generation, digest := g.value.Generation, g.value.SpecificationDigest + m.mu.Unlock() + value, err := m.options.Prepare(ctx, generation, digest) + cancel() + m.mu.Lock() + m.preparingCancel = nil + g.preparing = false + g.refs-- + if err == nil && (value.Generation != generation || value.SpecificationDigest != digest || sandbox.ValidateProvider(value.Provider) != nil || value.Probe == nil) { + err = sandbox.ErrOwnership + } + if err == nil { + if g.value.Close != nil { + g.value.Close() + } + g.value = value + g.repairing = false + g.failures = 0 + g.retryAt = time.Time{} + g.state = "preparing" + } else { + if value.Close != nil { + value.Close() + } + g.state = "failed" + g.diagnostic = sandbox.NodeDiagnostic(err) + g.failures++ + delays := []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 30 * time.Minute} + index := g.failures - 1 + if index >= len(delays) { + index = len(delays) - 1 + } + g.retryAt = time.Now().Add(delays[index]) + } + m.mu.Unlock() + } +} + +func (m *GenerationManager) probeLoop() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + } + m.mu.Lock() + keys := m.orderedLocked(m.probeCursor) + // Alternate priority probes with the fair retained-provider cursor. A large + // history cannot postpone serving/target qualification or starve old owners. + m.priorityProbe++ + priority := uint64(0) + if m.priorityProbe%2 == 1 { + priority = m.target.Generation + if m.priorityProbe%4 == 3 && m.target.ServingGeneration != nil { + priority = *m.target.ServingGeneration + } + keys = append([]uint64{priority}, keys...) + } + var g *localGeneration + for _, key := range keys { + value := m.values[key] + if value != nil && value.value.Provider != nil && !value.removing && !value.collecting && !value.preparing && !value.repairing { + g = value + if key != priority { + m.probeCursor = key + } + g.refs++ + break + } + } + m.mu.Unlock() + if g == nil { + continue + } + ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second) + err := g.value.Probe(ctx) + cancel() + m.mu.Lock() + g.refs-- + if !errors.Is(err, context.Canceled) { + g.state = "ready" + g.diagnostic = "" + if err != nil { + g.state = "failed" + g.diagnostic = sandbox.NodeDiagnostic(err) + if errors.Is(err, sandbox.ErrRuntimeImageUnavailable) || errors.Is(err, sandbox.ErrMicrosandboxArtifactsUnavailable) { + g.repairing = true + } + } + } + m.mu.Unlock() + } +} + +func (m *GenerationManager) Ready(generation uint64) bool { + m.mu.Lock() + defer m.mu.Unlock() + g := m.values[generation] + return g != nil && !g.removing && g.value.Provider != nil && g.state == "ready" +} diff --git a/services/core/internal/sandbox/node/generations_test.go b/services/core/internal/sandbox/node/generations_test.go new file mode 100644 index 000000000..9292bbbb2 --- /dev/null +++ b/services/core/internal/sandbox/node/generations_test.go @@ -0,0 +1,393 @@ +package node + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + "github.com/google/uuid" +) + +func generationFixture(count int) *GenerationManager { + m := &GenerationManager{values: map[uint64]*localGeneration{}, wake: make(chan struct{}, 1)} + for i := 1; i <= count; i++ { + g := uint64(i) + m.values[g] = &localGeneration{value: GenerationProvider{Generation: g, SpecificationDigest: strings.Repeat("a", 64), Provider: &fakeProvider{}}, state: "ready"} + } + pin := uint64(1) + m.target = sandbox.NodeDeployment{Generation: uint64(count), SpecificationDigest: strings.Repeat("a", 64), ServingGeneration: &pin} + return m +} + +func TestSparseGenerationControlHasNoLifetimeLimit(t *testing.T) { + for _, count := range []int{9, 17, 257} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + m := generationFixture(count) + statuses, retained := map[uint64]bool{}, map[uint64]bool{} + cursor := uint64(0) + for i := 0; i < count; i++ { + batch := m.Statuses() + if len(batch) > 8 || len(batch) < 2 || batch[0].Generation != uint64(count) || batch[1].Generation != 1 { + t.Fatal("unbounded batch or missing priorities", batch) + } + for _, item := range batch { + statuses[item.Generation] = true + } + refs := m.Retained(cursor) + if len(refs) > 8 { + t.Fatal("unbounded retention batch") + } + for _, ref := range refs { + retained[ref.Generation] = true + } + cursor = refs[len(refs)-1].Generation + } + if len(statuses) != count || len(retained) != count || len(m.values) != count { + t.Fatal("sparse rotation lost generations", len(statuses), len(retained), len(m.values)) + } + }) + } +} + +func TestRetentionReplyMustMatchWholePendingExchange(t *testing.T) { + for _, mutation := range []string{"id", "sequence", "connection", "epoch", "partial", "reordered", "digest"} { + t.Run(mutation, func(t *testing.T) { + m := generationFixture(3) + a := &agent{config: AgentConfig{Generations: m}} + c := &agentConnection{id: uuid.NewString(), epoch: 7} + refs := m.Retained(0) + c.pending = &generationControl{ID: uuid.NewString(), Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch, References: refs} + reply := &generationControl{ID: c.pending.ID, Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch} + for _, ref := range refs { + reply.Retentions = append(reply.Retentions, sandbox.GenerationRetention{GenerationReference: ref, Keep: false}) + } + switch mutation { + case "id": + reply.ID = uuid.NewString() + case "sequence": + reply.Sequence++ + case "connection": + reply.ConnectionID = uuid.NewString() + case "epoch": + reply.OwnerEpoch++ + case "partial": + reply.Retentions = reply.Retentions[:1] + case "reordered": + reply.Retentions[0], reply.Retentions[1] = reply.Retentions[1], reply.Retentions[0] + case "digest": + reply.Retentions[0].SpecificationDigest = strings.Repeat("b", 64) + } + work := make(chan []sandbox.GenerationRetention, 1) + f := frame{Control: reply, Deployment: &m.target} + if err := a.acceptRetention(c, f, work); err == nil { + t.Fatal("invalid grant accepted") + } + if len(work) != 0 || len(m.values) != 3 || c.pending == nil { + t.Fatal("invalid exchange authorized partial collection") + } + }) + } + m := generationFixture(3) + a := &agent{config: AgentConfig{Generations: m}} + c := &agentConnection{id: uuid.NewString(), epoch: 7} + refs := m.Retained(0) + c.pending = &generationControl{ID: uuid.NewString(), Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch, References: refs} + reply := &generationControl{ID: c.pending.ID, Sequence: 1, ConnectionID: c.id, OwnerEpoch: c.epoch} + for _, ref := range refs { + reply.Retentions = append(reply.Retentions, sandbox.GenerationRetention{GenerationReference: ref}) + } + work := make(chan []sandbox.GenerationRetention, 1) + f := frame{Control: reply, Deployment: &m.target} + if err := a.acceptRetention(c, f, work); err != nil { + t.Fatal(err) + } + if err := a.acceptRetention(c, f, work); err == nil { + t.Fatal("replayed grant accepted") + } + if len(work) != 1 { + t.Fatal("grant queued more than once") + } +} + +type helperOwnedProvider struct { + *fakeProvider + caller *microsandbox.ProcessCaller +} + +func (p *helperOwnedProvider) Quiescent() bool { return p.caller.Quiescent() } + +func TestGrantedDropWaitsForReferencesAndActualHelperExit(t *testing.T) { + root := t.TempDir() + helper := filepath.Join(root, "helper") + started := filepath.Join(root, "started") + release := filepath.Join(root, "release") + artifact := filepath.Join(root, "runtime") + // A local test helper uses files only as deterministic process barriers. Its + // response waiter returns on cancellation while the actual process stays alive. + script := "#!/bin/sh\ncat >/dev/null\nprintf started > '" + started + "'\nwhile [ ! -f '" + release + "' ]; do sleep 0.01; done\nprintf '{}\\n'\n" + if err := os.WriteFile(helper, []byte(script), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(artifact, []byte("retained bytes"), 0600); err != nil { + t.Fatal(err) + } + caller := µsandbox.ProcessCaller{} + defer func() { _ = os.WriteFile(release, nil, 0600); wait(t, caller.Quiescent) }() + m := generationFixture(3) + m.target.ServingGeneration = nil + m.values[1].value.Provider = &helperOwnedProvider{fakeProvider: &fakeProvider{}, caller: caller} + m.options.Remove = func(context.Context, GenerationProvider) error { return os.Remove(artifact) } + _, _, unref, err := m.Acquire(1) + if err != nil { + t.Fatal(err) + } + grant := sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}} + if err := m.Drop(t.Context(), grant); !errors.Is(err, ErrUnavailable) { + t.Fatal("queued reference did not retain bytes", err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, err := caller.Call(ctx, microsandbox.Request{Config: microsandbox.Config{HelperPath: helper}}) + done <- err + }() + wait(t, func() bool { _, err := os.Stat(started); return err == nil }) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + unref() + if caller.Quiescent() { + t.Fatal("caller cancellation pretended helper exited") + } + if err := m.Drop(t.Context(), grant); !errors.Is(err, ErrUnavailable) { + t.Fatal("live helper did not retain generation", err) + } + if _, err := os.Stat(artifact); err != nil { + t.Fatal("granted drop removed live helper bytes", err) + } + if err := os.WriteFile(release, nil, 0600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for !caller.Quiescent() && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if !caller.Quiescent() { + t.Fatal("actual helper Wait did not settle") + } + if err := m.Drop(t.Context(), grant); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(artifact); !os.IsNotExist(err) { + t.Fatal("settled unreferenced generation was not collected", err) + } +} + +func TestCanceledConnectionCannotBeginGenerationCollection(t *testing.T) { + m := generationFixture(3) + m.target.ServingGeneration = nil + removed := false + m.options.Remove = func(context.Context, GenerationProvider) error { removed = true; return nil } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + grant := sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}} + if err := m.Drop(ctx, grant); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if removed || m.values[1] == nil { + t.Fatal("canceled connection began collection") + } +} + +func TestRestartRecoversExactOlderGenerationBeforeAdvertisingReadiness(t *testing.T) { + digest := strings.Repeat("a", 64) + requested := make(chan uint64, 1) + download := make(chan struct{}) + probe := make(chan struct{}, 1) + qualify := make(chan struct{}) + m, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ + Initial: []GenerationProvider{{Generation: 2, SpecificationDigest: digest, Provider: &fakeProvider{}, Probe: func(context.Context) error { return nil }}}, + Recover: []sandbox.GenerationReference{{Generation: 1, SpecificationDigest: digest}}, + Prepare: func(ctx context.Context, generation uint64, got string) (GenerationProvider, error) { + if got != digest { + return GenerationProvider{}, sandbox.ErrOwnership + } + requested <- generation + select { + case <-download: + case <-ctx.Done(): + return GenerationProvider{}, ctx.Err() + } + return GenerationProvider{Generation: generation, SpecificationDigest: got, Provider: &fakeProvider{}, Probe: func(ctx context.Context) error { + select { + case probe <- struct{}{}: + default: + } + select { + case <-qualify: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }}, nil + }, + Remove: func(context.Context, GenerationProvider) error { return nil }, + }) + if err != nil { + t.Fatal(err) + } + defer m.Close() + pin := uint64(2) + m.Deployment(sandbox.NodeDeployment{Generation: 2, SpecificationDigest: digest, ServingGeneration: &pin}) + select { + case generation := <-requested: + if generation != 1 { + t.Fatal("replaced old placement generation", generation) + } + case <-time.After(3 * time.Second): + t.Fatal("old generation was never recovered") + } + if m.Ready(1) { + t.Fatal("missing payload advertised readiness") + } + if err := m.Drop(t.Context(), sandbox.GenerationRetention{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: digest}}); !errors.Is(err, ErrUnavailable) { + t.Fatal("collection entered active repair", err) + } + close(download) + select { + case <-probe: + case <-time.After(4 * time.Second): + t.Fatal("restored provider never qualified") + } + if m.Ready(1) { + t.Fatal("file presence advertised provider readiness") + } + close(qualify) + wait(t, func() bool { return m.Ready(1) }) + provider, ready, release, err := m.Acquire(1) + if err != nil || !ready || provider == nil { + t.Fatal("old generation not usable after actual qualification", err) + } + release() +} + +func TestGenerationDeploymentRejectsConflictingImmutableIdentity(t *testing.T) { + m := generationFixture(3) + bad := m.target + bad.SpecificationDigest = strings.Repeat("b", 64) + if err := m.Deployment(bad); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatal("conflicting target accepted", err) + } + if m.target.SpecificationDigest != strings.Repeat("a", 64) || m.values[3].value.SpecificationDigest != m.target.SpecificationDigest { + t.Fatal("rejected metadata changed provider identity") + } + bad = m.target + future := uint64(4) + bad.ServingGeneration = &future + if err := m.Deployment(bad); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatal("future serving pin accepted", err) + } +} + +func TestInterruptedCollectionNeverPreparesOrServesAfterRestart(t *testing.T) { + digest := strings.Repeat("a", 64) + ref := sandbox.GenerationReference{Generation: 1, SpecificationDigest: digest} + probed := make(chan struct{}, 1) + removes := 0 + manager, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ + Initial: []GenerationProvider{{Generation: 2, SpecificationDigest: digest, Provider: &fakeProvider{}, Probe: func(context.Context) error { + select { + case probed <- struct{}{}: + default: + } + return nil + }}}, + Collect: []sandbox.GenerationReference{ref}, + Prepare: func(context.Context, uint64, string) (GenerationProvider, error) { + t.Error("collection entered preparation") + return GenerationProvider{}, sandbox.ErrInvalid + }, + Remove: func(_ context.Context, value GenerationProvider) error { + removes++ + if value.Generation != 1 { + t.Error("wrong generation") + } + if removes == 1 { + return errors.New("interrupted cleanup") + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + pin := uint64(2) + if err = manager.Deployment(sandbox.NodeDeployment{Generation: 2, SpecificationDigest: digest, ServingGeneration: &pin}); err != nil { + t.Fatal(err) + } + select { + case <-probed: + case <-time.After(3 * time.Second): + t.Fatal("provider loop did not run") + } + if _, _, _, err = manager.Acquire(1); err == nil { + t.Fatal("unfinished collection admitted an operation") + } + for _, status := range manager.Statuses() { + if status.Generation == 1 { + t.Fatal("unfinished collection advertised readiness", status) + } + } + if len(manager.Retained(0)) != 2 || removes != 0 { + t.Fatal("restart consumed old drop authorization") + } + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + grant := sandbox.GenerationRetention{GenerationReference: ref} + if err = manager.Drop(cancelled, grant); err == nil || removes != 0 { + t.Fatal("cancelled connection collected") + } + if err = manager.Drop(t.Context(), grant); err == nil { + t.Fatal("expected interrupted cleanup") + } + if _, _, _, err = manager.Acquire(1); err == nil { + t.Fatal("failed cleanup resumed serving") + } + if err = manager.Drop(t.Context(), grant); err != nil { + t.Fatal(err) + } + if len(manager.Retained(0)) != 1 || removes != 2 { + t.Fatal("cleanup did not settle") + } +} + +func TestPreparationDiagnosticPreservesTypedCause(t *testing.T) { + for _, cause := range []error{sandbox.ErrRuntimeDownloadFailed, sandbox.ErrDockerUnavailable, sandbox.ErrKVMUnavailable, sandbox.ErrOwnership, context.Canceled, errors.New("raw secret provider text")} { + t.Run(sandbox.NodeDiagnostic(cause)+cause.Error(), func(t *testing.T) { + m, err := NewGenerationManager(t.Context(), GenerationManagerOptions{ + Prepare: func(context.Context, uint64, string) (GenerationProvider, error) { return GenerationProvider{}, cause }, + Remove: func(context.Context, GenerationProvider) error { return nil }, + }) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if err := m.Deployment(sandbox.NodeDeployment{Generation: 1, SpecificationDigest: strings.Repeat("a", 64)}); err != nil { + t.Fatal(err) + } + wait(t, func() bool { s := m.Statuses(); return len(s) == 1 && s[0].State == "failed" }) + if got := m.Statuses()[0].Diagnostic; got != sandbox.NodeDiagnostic(cause) { + t.Fatal("wrong fixed diagnostic", got) + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/node/health_cgroup_linux.go b/services/core/internal/sandbox/node/health_cgroup_linux.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_cgroup_linux.go rename to services/core/internal/sandbox/node/health_cgroup_linux.go diff --git a/services/agents-api/internal/sandbox/node/health_connection_linux_test.go b/services/core/internal/sandbox/node/health_connection_linux_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_connection_linux_test.go rename to services/core/internal/sandbox/node/health_connection_linux_test.go diff --git a/services/agents-api/internal/sandbox/node/health_cpu_linux.go b/services/core/internal/sandbox/node/health_cpu_linux.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_cpu_linux.go rename to services/core/internal/sandbox/node/health_cpu_linux.go diff --git a/services/agents-api/internal/sandbox/node/health_cpu_linux_test.go b/services/core/internal/sandbox/node/health_cpu_linux_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_cpu_linux_test.go rename to services/core/internal/sandbox/node/health_cpu_linux_test.go diff --git a/services/agents-api/internal/sandbox/node/health_linux.go b/services/core/internal/sandbox/node/health_linux.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_linux.go rename to services/core/internal/sandbox/node/health_linux.go diff --git a/services/agents-api/internal/sandbox/node/health_linux_test.go b/services/core/internal/sandbox/node/health_linux_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_linux_test.go rename to services/core/internal/sandbox/node/health_linux_test.go diff --git a/services/agents-api/internal/sandbox/node/health_other.go b/services/core/internal/sandbox/node/health_other.go similarity index 100% rename from services/agents-api/internal/sandbox/node/health_other.go rename to services/core/internal/sandbox/node/health_other.go diff --git a/services/agents-api/internal/sandbox/node/hub.go b/services/core/internal/sandbox/node/hub.go similarity index 99% rename from services/agents-api/internal/sandbox/node/hub.go rename to services/core/internal/sandbox/node/hub.go index 5a1b4d89e..bda1da1c4 100644 --- a/services/agents-api/internal/sandbox/node/hub.go +++ b/services/core/internal/sandbox/node/hub.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/sandbox/node/hub_lifetime.go b/services/core/internal/sandbox/node/hub_lifetime.go similarity index 100% rename from services/agents-api/internal/sandbox/node/hub_lifetime.go rename to services/core/internal/sandbox/node/hub_lifetime.go diff --git a/services/agents-api/internal/sandbox/node/hub_lifetime_test.go b/services/core/internal/sandbox/node/hub_lifetime_test.go similarity index 99% rename from services/agents-api/internal/sandbox/node/hub_lifetime_test.go rename to services/core/internal/sandbox/node/hub_lifetime_test.go index 27d4dbc23..8d00a7451 100644 --- a/services/agents-api/internal/sandbox/node/hub_lifetime_test.go +++ b/services/core/internal/sandbox/node/hub_lifetime_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/sandbox/node/hub_postgres_test.go b/services/core/internal/sandbox/node/hub_postgres_test.go similarity index 100% rename from services/agents-api/internal/sandbox/node/hub_postgres_test.go rename to services/core/internal/sandbox/node/hub_postgres_test.go diff --git a/services/core/internal/sandbox/node/identity.go b/services/core/internal/sandbox/node/identity.go new file mode 100644 index 000000000..86ab0a187 --- /dev/null +++ b/services/core/internal/sandbox/node/identity.go @@ -0,0 +1,167 @@ +package node + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/url" + "os" + "path/filepath" + "syscall" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" + "github.com/google/uuid" +) + +type StoredIdentity struct { + Identity Identity `json:"identity"` + Credential string `json:"credential"` + CoreURL string `json:"core_url"` + OwnerEpoch uint64 `json:"owner_epoch"` +} + +func lockDirectory(dir string) (func(), error) { + if !filepath.IsAbs(dir) || filepath.Clean(dir) != dir { + return nil, errors.New("node state directory must be canonical and absolute") + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + st, err := os.Lstat(dir) + if err != nil || !st.IsDir() || st.Mode().Perm() != 0700 { + return nil, errors.New("node state directory must be private") + } + fd, err := syscall.Open(filepath.Join(dir, "identity.lock"), syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) + if err != nil { + return nil, err + } + if err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = syscall.Close(fd) + return nil, errors.New("sandbox node identity is already in use") + } + return func() { _ = syscall.Flock(fd, syscall.LOCK_UN); _ = syscall.Close(fd) }, nil +} +func readIdentity(dir string) (StoredIdentity, error) { + var out StoredIdentity + fd, err := syscall.Open(filepath.Join(dir, "identity.json"), syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) + if err != nil { + return out, err + } + f := os.NewFile(uintptr(fd), "identity.json") + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { + return out, errors.New("node identity must be a private regular file") + } + d := json.NewDecoder(io.LimitReader(f, 16384)) + d.DisallowUnknownFields() + if d.Decode(&out) != nil || d.Decode(new(any)) != io.EOF || !validID(out.Identity.NodeID) || len(out.Credential) != 64 { + return StoredIdentity{}, errors.New("invalid node identity") + } + secret, err := hex.DecodeString(out.Credential) + if err != nil || len(secret) != 32 || hex.EncodeToString(secret) != out.Credential { + return StoredIdentity{}, errors.New("invalid node identity") + } + return out, nil +} +func writeIdentity(dir string, s StoredIdentity) error { + data, err := json.Marshal(s) + if err != nil { + return err + } + f, err := os.CreateTemp(dir, ".identity-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if err = os.Rename(f.Name(), filepath.Join(dir, "identity.json")); err != nil { + return err + } + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +// InitIdentity creates the credential before any enrollment request. A lost +// enrollment response can therefore be recovered by authenticating this identity. +func InitIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { + release, err := lockDirectory(dir) + if err != nil { + return StoredIdentity{}, err + } + defer release() + return initIdentity(dir, coreURL, identity) +} +func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { + if _, err := endpoint(coreURL, ""); err != nil { + return StoredIdentity{}, err + } + stored, err := readIdentity(dir) + if err == nil { + expected := identity + if expected.NodeID == "" { + expected.NodeID = stored.Identity.NodeID + } + if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL { + return StoredIdentity{}, errors.New("node configuration does not match its retained identity") + } + return stored, nil + } + if !os.IsNotExist(err) { + return StoredIdentity{}, err + } + if identity.NodeID == "" { + identity.NodeID = uuid.NewString() + } + if !validID(identity.NodeID) || !validID(identity.InstallationID) || !providers.IsNode(identity.Provider) || len(identity.BackendFingerprint) != 64 { + return StoredIdentity{}, errors.New("invalid node configuration") + } + secret := make([]byte, 32) + if _, err = rand.Read(secret); err != nil { + return StoredIdentity{}, err + } + stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL} + if err = writeIdentity(dir, stored); err != nil { + return StoredIdentity{}, err + } + return stored, nil +} +func LoadIdentity(dir string) (StoredIdentity, error) { + release, err := lockDirectory(dir) + if err != nil { + return StoredIdentity{}, err + } + defer release() + return readIdentity(dir) +} + +func endpoint(raw, path string) (string, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return "", errors.New("node Core URL must be an origin") + } + if u.Scheme != "https" { + ip := net.ParseIP(u.Hostname()) + if u.Scheme != "http" || !(u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { + return "", errors.New("remote node Core URL requires HTTPS") + } + } + u.Path = path + return u.String(), nil +} diff --git a/services/agents-api/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go similarity index 99% rename from services/agents-api/internal/sandbox/node/node_test.go rename to services/core/internal/sandbox/node/node_test.go index 6e1e5f424..792bc3f48 100644 --- a/services/agents-api/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/core/internal/sandbox/node/observations.go b/services/core/internal/sandbox/node/observations.go new file mode 100644 index 000000000..253301087 --- /dev/null +++ b/services/core/internal/sandbox/node/observations.go @@ -0,0 +1,52 @@ +package node + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +var _ runtimeobs.Source = (*provider)(nil) + +func (p *provider) ObservationProviderType() string { return p.kind } + +func observationReference(target runtimeobs.Target) sandbox.Reference { + return sandbox.Reference{TenantID: target.TenantID, EnvironmentID: target.EnvironmentID, AllocationID: target.Instance.AllocationID} +} + +func validObservation(target runtimeobs.Target, reference sandbox.Reference) bool { + return target.Mode == runtimeobs.ModeManaged && validID(target.SessionID) && + validID(target.Instance.ProviderKey) && observationReference(target) == reference && target.TokenUsage == nil +} + +// Observe uses the allocation's existing node resolver and never changes its +// compute state. Session token counters stay in Core, outside provider telemetry. +func (p *provider) Observe(ctx context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { + target.TokenUsage = nil + out, err := p.call(ctx, request{Operation: "observe", Reference: observationReference(target), Observation: &target}) + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) && + (errors.Is(err, ErrUnavailable) || errors.Is(err, sandbox.ErrComputeUnconfirmed)) { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + return runtimeobs.Sample{}, err + } + if out.Sample == nil { + return runtimeobs.Sample{}, runtimeobs.ErrUnavailable + } + return *out.Sample, nil +} + +func observeProvider(ctx context.Context, provider sandbox.SandboxProvider, target runtimeobs.Target) (runtimeobs.Sample, error) { + if err := providercontract.Require(provider, "Observe"); err != nil { + return runtimeobs.Sample{}, err + } + source, ok := provider.(runtimeobs.Source) + if !ok { + return runtimeobs.Sample{}, providercontract.ErrContract + } + return source.Observe(ctx, target) +} diff --git a/services/core/internal/sandbox/node/observations_test.go b/services/core/internal/sandbox/node/observations_test.go new file mode 100644 index 000000000..bec9ec874 --- /dev/null +++ b/services/core/internal/sandbox/node/observations_test.go @@ -0,0 +1,172 @@ +package node + +import ( + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "net/http/httptest" + "reflect" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +type observationProvider struct { + *fakeProvider + observationMu sync.Mutex + targets []runtimeobs.Target + sample runtimeobs.Sample + err error +} + +func (p *observationProvider) Observe(_ context.Context, target runtimeobs.Target) (runtimeobs.Sample, error) { + p.observationMu.Lock() + defer p.observationMu.Unlock() + p.targets = append(p.targets, target) + if target.Instance.ComputePhase == "suspended" { + return runtimeobs.Sample{}, runtimeobs.ErrNotRunning + } + return p.sample, p.err +} +func observationTarget(r sandbox.Reference, installation string) runtimeobs.Target { + return runtimeobs.Target{TenantID: r.TenantID, SessionID: uuid.NewString(), EnvironmentID: r.EnvironmentID, Mode: runtimeobs.ModeManaged, + Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: installation, AllocationState: "running", ComputePhase: "running", ProviderState: json.RawMessage(`{"current":{"id":"exact-incarnation","generation":3}}`)}, + TokenUsage: &runtimeobs.TokenUsage{InputTokens: 123, OutputTokens: 456}} +} +func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { + t.Helper() + dir := stateDir(t) + stored, err := InitIdentity(dir, url, id) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: url, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: provider, Probe: func(context.Context) (Health, error) { return Health{}, errors.New("provider not ready for creation") }}) + }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(5 * time.Second): + t.Error("observation node did not stop") + } + }) + wait(t, func() bool { return hub.Online(id.NodeID) }) + return cancel +} +func TestObservationsRouteThroughAssignedNodeWithoutLifecycleCalls(t *testing.T) { + first, second := identity(), identity() + second.InstallationID = first.InstallationID + hub := NewHub(HubOptions{Authenticate: func(_ context.Context, id, _ string) (Identity, error) { + switch id { + case first.NodeID: + return first, nil + case second.NodeID: + return second, nil + } + return Identity{}, ErrAuthentication + }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) + server := httptest.NewServer(hub) + t.Cleanup(server.Close) + t.Cleanup(hub.Close) + zero, bytes := uint64(0), uint64(1234) + now := time.Now().UTC().Truncate(time.Microsecond) + a := &observationProvider{fakeProvider: &fakeProvider{}, sample: runtimeobs.Sample{ObservedAt: now, MemoryUsageBytes: &zero}} + b := &observationProvider{fakeProvider: &fakeProvider{}, sample: runtimeobs.Sample{ObservedAt: now, MemoryUsageBytes: &bytes}} + runObservationNode(t, hub, server.URL, first, a) + stopSecond := runObservationNode(t, hub, server.URL, second, b) + ra, rb := reference(), reference() + assignments := map[sandbox.Reference]string{ra: first.NodeID, rb: second.NodeID} + source := hub.GenerationProvider("docker", func(_ context.Context, r sandbox.Reference) (string, uint64, error) { + if id, ok := assignments[r]; ok { + return id, 1, nil + } + return "", 0, sandbox.ErrOwnership + }).(runtimeobs.Source) + if typed := source.(interface{ ObservationProviderType() string }).ObservationProviderType(); typed != "docker" { + t.Fatal("provider type lost", typed) + } + for _, test := range []struct { + ref sandbox.Reference + provider *observationProvider + }{{ra, a}, {rb, b}} { + target := observationTarget(test.ref, first.InstallationID) + sample, err := source.Observe(t.Context(), target) + if err != nil || !reflect.DeepEqual(sample, test.provider.sample) { + t.Fatal("observation crossed node or changed sample", sample, err) + } + test.provider.observationMu.Lock() + observed := test.provider.targets[0] + test.provider.observationMu.Unlock() + target.TokenUsage = nil + if !reflect.DeepEqual(observed, target) { + t.Fatal("durable observation target changed", observed, target) + } + target.Instance.ComputePhase = "suspended" + if _, err := source.Observe(t.Context(), target); !errors.Is(err, runtimeobs.ErrNotRunning) { + t.Fatal("suspended observation did not remain stopped", err) + } + } + if _, err := source.Observe(t.Context(), observationTarget(reference(), first.InstallationID)); !errors.Is(err, sandbox.ErrOwnership) { + t.Fatal("unknown allocation was routed", err) + } + for _, p := range []*observationProvider{a, b} { + p.mu.Lock() + if p.creates != 0 || p.kills != 0 || p.reads != 0 { + t.Error("observation invoked lifecycle provider methods") + } + p.mu.Unlock() + } + stopSecond() + wait(t, func() bool { return !hub.Online(second.NodeID) }) + if _, err := source.Observe(t.Context(), observationTarget(rb, first.InstallationID)); !errors.Is(err, runtimeobs.ErrUnavailable) { + t.Fatal("offline source fell back", err) + } +} + +func TestObservationWirePreservesUnavailableAndRejectsMismatchedIdentity(t *testing.T) { + r := reference() + target := observationTarget(r, uuid.NewString()) + target.TokenUsage = nil + q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Operation: "observe", Reference: r, Observation: &target, TimeoutMillis: 1000} + providers := []struct { + name string + provider sandbox.SandboxProvider + want error + }{ + {"unsupported", &fakeProvider{}, providercontract.ErrUnsupported}, + {"unavailable", &observationProvider{fakeProvider: &fakeProvider{}, err: runtimeobs.ErrUnavailable}, runtimeobs.ErrUnavailable}, + {"stopped", &observationProvider{fakeProvider: &fakeProvider{}, err: runtimeobs.ErrNotRunning}, runtimeobs.ErrNotRunning}, + {"ownership", &observationProvider{fakeProvider: &fakeProvider{}, err: sandbox.ErrOwnership}, sandbox.ErrOwnership}, + } + for _, test := range providers { + t.Run(test.name, func(t *testing.T) { + out := execute(t.Context(), test.provider, q) + if !errors.Is(responseError(out), test.want) || out.Sample != nil { + t.Fatal("observation error or missing sample was fabricated", out) + } + }) + } + p := &observationProvider{fakeProvider: &fakeProvider{}} + for _, mutate := range []func(*runtimeobs.Target){func(t *runtimeobs.Target) { t.EnvironmentID = uuid.NewString() }, func(t *runtimeobs.Target) { t.Instance.AllocationID = uuid.NewString() }, func(t *runtimeobs.Target) { t.TenantID = uuid.NewString() }, func(t *runtimeobs.Target) { t.TokenUsage = &runtimeobs.TokenUsage{} }, func(t *runtimeobs.Target) { t.Mode = runtimeobs.ModeSelfHosted }} { + invalid := target + mutate(&invalid) + q.Observation = &invalid + if out := execute(t.Context(), p, q); !errors.Is(responseError(out), sandbox.ErrInvalid) { + t.Fatal("mismatched observation reached provider", out) + } + } + if len(p.targets) != 0 { + t.Fatal("invalid target was observed") + } +} diff --git a/services/agents-api/internal/sandbox/node/operations.go b/services/core/internal/sandbox/node/operations.go similarity index 100% rename from services/agents-api/internal/sandbox/node/operations.go rename to services/core/internal/sandbox/node/operations.go diff --git a/services/core/internal/sandbox/node/operations_test.go b/services/core/internal/sandbox/node/operations_test.go new file mode 100644 index 000000000..dfafaf4c0 --- /dev/null +++ b/services/core/internal/sandbox/node/operations_test.go @@ -0,0 +1,58 @@ +package node + +import ( + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + "testing" +) + +func TestUnsupportedWireIsExplicitAndDoesNotInvokeProvider(t *testing.T) { + p := &fakeProvider{} + q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Reference: reference(), TimeoutMillis: 1000, Operation: "initial"} + out := execute(t.Context(), p, q) + if p.creates != 0 || p.reads != 0 || p.kills != 0 { + t.Fatal("unsupported operation reached provider") + } + raw, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + var decoded response + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if reason, ok := providercontract.UnsupportedReason(responseError(decoded), "Initial"); !ok || reason != "fixture_operation_not_supported" { + t.Fatal(decoded) + } + for _, bad := range []response{{ErrorCode: "unsupported"}, {ErrorCode: "unsupported", Unsupported: &providercontract.UnsupportedError{Operation: "Initial", Reason: "private / credential"}}, {Unsupported: out.Unsupported}} { + if !errors.Is(responseError(bad), sandbox.ErrComputeUnconfirmed) { + t.Fatal("malformed failure became certainty", bad) + } + } +} +func TestUnsupportedProxyRejectsBeforeNodeResolution(t *testing.T) { + p := &provider{kind: "docker", resolveGeneration: func(context.Context, sandbox.Reference) (string, uint64, error) { + t.Fatal("unsupported call resolved a node") + return "", 0, nil + }} + if err := sandbox.ValidateProvider(p); err != nil { + t.Fatal(err) + } + if _, err := p.Initial(t.Context(), reference()); !errors.Is(err, providercontract.ErrUnsupported) { + t.Fatal(err) + } + if _, err := p.ObserveBatch(t.Context(), nil); !errors.Is(err, providercontract.ErrUnsupported) { + t.Fatal(err) + } +} +func TestNodeOperationMappingCoversForwardedMethods(t *testing.T) { + for _, method := range []string{"Create", "GetInfo", "Renew", "Kill", "RunCommand", "Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute", "Observe"} { + if wire := operationWire(method); wire == "" || operationMethod(wire) != method { + t.Fatal(method) + } + } +} diff --git a/services/core/internal/sandbox/node/provider_operations_fixture_test.go b/services/core/internal/sandbox/node/provider_operations_fixture_test.go new file mode 100644 index 000000000..56c21119b --- /dev/null +++ b/services/core/internal/sandbox/node/provider_operations_fixture_test.go @@ -0,0 +1,92 @@ +package node + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func (*fakeProvider) ProviderOperations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + } +} +func (*fakeProvider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { + return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { + return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} +} +func (*fakeProvider) VerifyCredential(context.Context, []sandbox.Reference) error { + return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} +} +func (*observationProvider) ProviderOperations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Observe": {State: providercontract.Supported}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + } +} diff --git a/services/agents-api/internal/sandbox/node/proxy.go b/services/core/internal/sandbox/node/proxy.go similarity index 95% rename from services/agents-api/internal/sandbox/node/proxy.go rename to services/core/internal/sandbox/node/proxy.go index ec0d7ba77..cf30880ea 100644 --- a/services/agents-api/internal/sandbox/node/proxy.go +++ b/services/core/internal/sandbox/node/proxy.go @@ -3,11 +3,11 @@ package node import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) type provider struct { diff --git a/services/core/internal/sandbox/node/recovery_test.go b/services/core/internal/sandbox/node/recovery_test.go new file mode 100644 index 000000000..d22d8a270 --- /dev/null +++ b/services/core/internal/sandbox/node/recovery_test.go @@ -0,0 +1,281 @@ +package node + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + "github.com/gorilla/websocket" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) { + id := identity() + var credential string + options := func(epoch uint64) HubOptions { + return HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return epoch, nil }} + } + first := NewHub(options(4)) + second := NewHub(options(5)) + defer first.Close() + defer second.Close() + var mu sync.Mutex + current := first + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock(); h := current; mu.Unlock(); h.ServeHTTP(w, r) })) + defer server.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + credential = stored.Credential + start := func() (context.CancelFunc, chan error) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: credential, Provider: &fakeProvider{}, Probe: probe}) + }() + return cancel, done + } + stop, done := start() + defer func() { stop() }() + wait(t, func() bool { return first.Online(id.NodeID) }) + mu.Lock() + current = second + mu.Unlock() + first.Close() + wait(t, func() bool { return second.Online(id.NodeID) }) + if first.Online(id.NodeID) { + t.Fatal("old owner remained online") + } + if _, err = first.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatalf("old owner request = %v", err) + } + if _, err = second.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); err != nil { + t.Fatal(err) + } + stop() + if err = <-done; err != nil { + t.Fatal(err) + } + wait(t, func() bool { return !second.Online(id.NodeID) }) + persisted, err := LoadIdentity(dir) + if err != nil || persisted.OwnerEpoch != 5 || persisted.Credential != credential { + t.Fatal("restart identity changed") + } + stop, done = start() + wait(t, func() bool { return second.Online(id.NodeID) }) + stop() + if err = <-done; err != nil { + t.Fatal(err) + } +} + +func TestAgentRejectsOwnerEpochRollback(t *testing.T) { + id := identity() + upgrade := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, e := upgrade.Upgrade(w, r, nil) + if e != nil { + return + } + defer conn.Close() + _, _ = readFrame(conn) + _ = writeFrame(conn, frame{Type: "welcome", ConnectionID: uuid.NewString(), OwnerEpoch: 3}) + _, _ = readFrame(conn) + })) + defer server.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + stored.OwnerEpoch = 4 + if err = writeIdentity(dir, stored); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + err = Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: &fakeProvider{}, Probe: probe}) + if !errors.Is(err, sandbox.ErrOwnership) { + t.Fatalf("stale owner = %v", err) + } +} + +func TestHeartbeatAcknowledgementKeepsIdleConnectionAlive(t *testing.T) { + id := identity() + beats := make(chan Health, 1) + var heartbeats int + hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Heartbeat: func(ctx context.Context, id Identity, connection string, epoch uint64, h Health) error { + heartbeats++ + if heartbeats == 1 { + return nil + } + select { + case beats <- h: + default: + } + return nil + }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: &fakeProvider{}, Probe: probe}) + }() + defer func() { + cancel() + if err := <-done; err != nil { + t.Error(err) + } + }() + select { + case h := <-beats: + if !h.ProviderReady || h.ObservedAt.IsZero() { + t.Fatal("invalid heartbeat") + } + case <-time.After(12 * time.Second): + t.Fatal("missing heartbeat") + } + if !hub.Online(id.NodeID) { + t.Fatal("idle node disconnected") + } + if _, err = hub.Proxy(id.NodeID, "docker", 1).GetInfo(ctx, reference()); err != nil { + t.Fatal(err) + } +} + +func TestHubExpiresSilentNode(t *testing.T) { + if testing.Short() { + t.Skip("real heartbeat timeout") + } + id := identity() + hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + url := "ws" + strings.TrimPrefix(server.URL, "http") + "?node_id=" + id.NodeID + conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": []string{"Bearer test"}}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { + t.Fatal(err) + } + if _, err = readFrame(conn); err != nil { + t.Fatal(err) + } + wait(t, func() bool { return hub.Online(id.NodeID) }) + assertDuplicateRejected(t, server.URL, id.NodeID, "test") + _ = conn.SetReadDeadline(time.Now().Add(38 * time.Second)) + if _, err = readFrame(conn); err == nil { + t.Fatal("silent node connection survived deadline") + } + wait(t, func() bool { return !hub.Online(id.NodeID) }) + wait(t, func() bool { return reservationReleased(hub, id.NodeID) }) + replacement := connectRawNode(t, server.URL, id) + defer replacement.Close() + wait(t, func() bool { return hub.Online(id.NodeID) }) +} + +func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { + id := identity() + health := make(chan Health, 1) + hub := NewHub(HubOptions{Authenticate: func(context.Context, string, string) (Identity, error) { return id, nil }, OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }, Heartbeat: func(ctx context.Context, id Identity, connection string, epoch uint64, h Health) error { + select { + case health <- h: + default: + } + return nil + }}) + server := httptest.NewServer(hub) + defer server.Close() + defer hub.Close() + dir := stateDir(t) + stored, err := InitIdentity(dir, server.URL, id) + if err != nil { + t.Fatal(err) + } + p := &fakeProvider{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, AgentConfig{CoreURL: server.URL, StateDirectory: dir, Identity: id, Credential: stored.Credential, Provider: p, Probe: func(context.Context) (Health, error) { return Health{}, errors.New("private backend detail") }}) + }() + defer func() { + cancel() + if err := <-done; err != nil { + t.Error(err) + } + }() + wait(t, func() bool { return hub.Online(id.NodeID) }) + select { + case h := <-health: + if h.ProviderReady || h.Diagnostic != "provider_unavailable" { + t.Fatalf("unsafe health: %+v", h) + } + case <-time.After(time.Second): + t.Fatal("missing health") + } + proxy := hub.Proxy(id.NodeID, "docker", 1) + r := reference() + if _, err = proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + t.Fatalf("create = %v", err) + } + if _, err = proxy.GetInfo(ctx, r); err != nil { + t.Fatal(err) + } + if err = proxy.Kill(ctx, r); err != nil { + t.Fatal(err) + } + p.mu.Lock() + defer p.mu.Unlock() + if p.creates != 0 || p.kills != 1 || p.reads != 1 { + t.Fatal("degraded node incorrectly dispatched work") + } +} + +func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { + id := identity() + dir := stateDir(t) + stored, err := InitIdentity(dir, "https://core.example.test", id) + if err != nil { + t.Fatal(err) + } + mismatch := id + mismatch.BackendFingerprint = strings.Repeat("2", 64) + if _, err = InitIdentity(dir, stored.CoreURL, mismatch); err == nil { + t.Fatal("adopted wrong backend") + } + original, err := LoadIdentity(dir) + if err != nil || original.Credential != stored.Credential { + t.Fatal("credential rotated") + } + if err = os.WriteFile(filepath.Join(dir, "identity.json"), []byte("corrupt"), 0600); err != nil { + t.Fatal(err) + } + if _, err = InitIdentity(dir, stored.CoreURL, id); err == nil { + t.Fatal("corrupt identity replaced") + } + if err = os.Remove(filepath.Join(dir, "identity.json")); err != nil { + t.Fatal(err) + } + if _, err = LoadIdentity(dir); err == nil { + t.Fatal("missing identity recreated by load") + } +} diff --git a/services/agents-api/internal/sandbox/node/timeout_test.go b/services/core/internal/sandbox/node/timeout_test.go similarity index 98% rename from services/agents-api/internal/sandbox/node/timeout_test.go rename to services/core/internal/sandbox/node/timeout_test.go index 5d82ca48a..b8832a6e3 100644 --- a/services/agents-api/internal/sandbox/node/timeout_test.go +++ b/services/core/internal/sandbox/node/timeout_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/core/internal/sandbox/node/wire.go similarity index 98% rename from services/agents-api/internal/sandbox/node/wire.go rename to services/core/internal/sandbox/node/wire.go index 53a34f2c2..0194e3c82 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/core/internal/sandbox/node/wire.go @@ -7,12 +7,12 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "io" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/sandbox/node_diagnostic.go b/services/core/internal/sandbox/node_diagnostic.go similarity index 100% rename from services/agents-api/internal/sandbox/node_diagnostic.go rename to services/core/internal/sandbox/node_diagnostic.go diff --git a/services/core/internal/sandbox/operations.go b/services/core/internal/sandbox/operations.go new file mode 100644 index 000000000..817a7baa7 --- /dev/null +++ b/services/core/internal/sandbox/operations.go @@ -0,0 +1,80 @@ +package sandbox + +import ( + "errors" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "reflect" +) + +// These existing interfaces are the canonical operation inventory. Declarations +// must cover every method, including explicit unsupported implementations. +var providerInterfaces = []reflect.Type{ + reflect.TypeFor[SandboxProvider](), reflect.TypeFor[CheckpointProvider](), + reflect.TypeFor[SelectionDiscoverer](), reflect.TypeFor[CredentialVerifier](), + reflect.TypeFor[runtimeobs.Source](), reflect.TypeFor[runtimeobs.BatchSource](), +} + +func ValidateProvider(p SandboxProvider) error { + if err := providercontract.Validate(p, providerInterfaces...); err != nil { + return err + } + return ValidateOperations(p.ProviderOperations()) +} + +// ValidateOperations rejects omitted, unknown and contradictory declarations. +func ValidateOperations(operations providercontract.Operations) error { + known := map[string]bool{} + for _, contract := range providerInterfaces { + for i := 0; i < contract.NumMethod(); i++ { + name := contract.Method(i).Name + if name != "ProviderOperations" { + known[name] = true + if err := operations[name].Check(name); err != nil && !errors.Is(err, providercontract.ErrUnsupported) { + return err + } + } + } + } + for name := range operations { + if !known[name] { + return fmt.Errorf("%w: unknown operation %s", providercontract.ErrContract, name) + } + } + required := reflect.TypeFor[SandboxProvider]() + for i := 0; i < required.NumMethod(); i++ { + name := required.Method(i).Name + if name != "ProviderOperations" && operations[name].State != providercontract.Supported { + return fmt.Errorf("%w: required operation %s", providercontract.ErrContract, name) + } + } + // The checkpoint lifecycle is indivisible: partial cleanup or restore support + // cannot safely own a compute incarnation. + checkpoint := reflect.TypeFor[CheckpointProvider]() + for i := 0; i < checkpoint.NumMethod(); i++ { + name := checkpoint.Method(i).Name + if _, required := reflect.TypeFor[SandboxProvider]().MethodByName(name); !required && operations[name].State != operations["Initial"].State { + return fmt.Errorf("%w: incomplete checkpoint lifecycle", providercontract.ErrContract) + } + } + if operations["ObserveBatch"].State == providercontract.Supported && operations["Observe"].State != providercontract.Supported { + return fmt.Errorf("%w: batch observation requires observation", providercontract.ErrContract) + } + return nil +} + +func SupportsCheckpoint(p SandboxProvider) bool { + return providercontract.Require(p, "Initial") == nil +} + +func Checkpoint(p SandboxProvider) (CheckpointProvider, error) { + if err := providercontract.Require(p, "Initial"); err != nil { + return nil, err + } + cp, ok := p.(CheckpointProvider) + if !ok { + return nil, providercontract.ErrContract + } + return cp, nil +} diff --git a/services/core/internal/sandbox/operations_test.go b/services/core/internal/sandbox/operations_test.go new file mode 100644 index 000000000..0b0dafafa --- /dev/null +++ b/services/core/internal/sandbox/operations_test.go @@ -0,0 +1,112 @@ +package sandbox_test + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + "reflect" + "testing" +) + +type changedDeclaration struct { + *docker.Provider + operations providercontract.Operations +} + +func (p *changedDeclaration) ProviderOperations() providercontract.Operations { return p.operations } + +type onlyRequired struct{ sandbox.SandboxProvider } + +func (*onlyRequired) ProviderOperations() providercontract.Operations { return docker.Operations() } + +func TestDeclarationsRejectMissingUnknownAndContradictoryOperations(t *testing.T) { + for _, mutate := range []struct { + name string + change func(providercontract.Operations) + }{ + {"omitted", func(o providercontract.Operations) { delete(o, "DeleteSnapshot") }}, + {"zero", func(o providercontract.Operations) { o["ObserveBatch"] = providercontract.Support{} }}, + {"unknown", func(o providercontract.Operations) { + o["FutureOperation"] = providercontract.Support{State: providercontract.Supported} + }}, + {"required unsupported", func(o providercontract.Operations) { + o["Renew"] = providercontract.Support{State: providercontract.Unsupported, Reason: "no_native_lease"} + }}, + {"partial checkpoint", func(o providercontract.Operations) { + o["Initial"] = providercontract.Support{State: providercontract.Supported} + }}, + {"unsafe reason", func(o providercontract.Operations) { + o["ObserveBatch"] = providercontract.Support{State: providercontract.Unsupported, Reason: "https://private:key@host"} + }}, + {"unknown state", func(o providercontract.Operations) { + o["ObserveBatch"] = providercontract.Support{State: "unavailable"} + }}, + } { + t.Run(mutate.name, func(t *testing.T) { + o := docker.Operations() + mutate.change(o) + if err := sandbox.ValidateProvider(&changedDeclaration{Provider: &docker.Provider{}, operations: o}); !errors.Is(err, providercontract.ErrContract) { + t.Fatal(err) + } + }) + } + if err := sandbox.ValidateProvider(&onlyRequired{}); !errors.Is(err, providercontract.ErrContract) { + t.Fatal("missing extension implementations accepted", err) + } + var nilProvider *docker.Provider + if err := sandbox.ValidateProvider(nilProvider); !errors.Is(err, providercontract.ErrContract) { + t.Fatal("typed nil accepted", err) + } +} + +// Unsupported methods must be callable safely even without native clients or +// configuration: any attempt at native I/O would panic on these zero providers. +func TestEveryUnsupportedNativeOperationRejectsWithoutSideEffects(t *testing.T) { + for _, provider := range []sandbox.SandboxProvider{&docker.Provider{}, &e2b.Provider{}, µsandbox.Provider{}} { + if err := sandbox.ValidateProvider(provider); err != nil { + t.Fatal(err) + } + for name, support := range provider.ProviderOperations() { + if support.State != providercontract.Unsupported { + continue + } + method := reflect.ValueOf(provider).MethodByName(name) + arguments := make([]reflect.Value, method.Type().NumIn()) + for i := range arguments { + arguments[i] = reflect.Zero(method.Type().In(i)) + } + arguments[0] = reflect.ValueOf(context.Background()) + values := method.Call(arguments) + err, _ := values[len(values)-1].Interface().(error) + reason, ok := providercontract.UnsupportedReason(err, name) + if !ok || reason != support.Reason { + t.Fatalf("%T.%s returned %v", provider, name, err) + } + for _, v := range values[:len(values)-1] { + if !v.IsZero() { + t.Fatalf("%s fabricated a successful value", name) + } + } + } + } +} + +// An extended interface cannot inherit success through the existing declaration. +type nextContract interface { + sandbox.SandboxProvider + NextOperation(context.Context) error +} +type futureProvider struct{ *docker.Provider } + +func (*futureProvider) NextOperation(context.Context) error { return nil } +func TestNewContractRequiresAnAuthoredDecision(t *testing.T) { + for _, p := range []providercontract.Declared{&docker.Provider{}, &futureProvider{&docker.Provider{}}} { + if err := providercontract.Validate(p, reflect.TypeFor[nextContract]()); !errors.Is(err, providercontract.ErrContract) { + t.Fatal("new operation inherited a default", err) + } + } +} diff --git a/services/agents-api/internal/sandbox/providers/capacity.go b/services/core/internal/sandbox/providers/capacity.go similarity index 84% rename from services/agents-api/internal/sandbox/providers/capacity.go rename to services/core/internal/sandbox/providers/capacity.go index 9420953d5..1b569c132 100644 --- a/services/agents-api/internal/sandbox/providers/capacity.go +++ b/services/core/internal/sandbox/providers/capacity.go @@ -3,7 +3,7 @@ package providers import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func checkCapacity(r sandbox.Resources, cpus int, memory uint64) error { diff --git a/services/agents-api/internal/sandbox/providers/capacity_linux.go b/services/core/internal/sandbox/providers/capacity_linux.go similarity index 82% rename from services/agents-api/internal/sandbox/providers/capacity_linux.go rename to services/core/internal/sandbox/providers/capacity_linux.go index 017dfdc90..fbadd8abb 100644 --- a/services/agents-api/internal/sandbox/providers/capacity_linux.go +++ b/services/core/internal/sandbox/providers/capacity_linux.go @@ -7,7 +7,7 @@ import ( "runtime" "syscall" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func hostCapacity(r sandbox.Resources) error { diff --git a/services/core/internal/sandbox/providers/capacity_other.go b/services/core/internal/sandbox/providers/capacity_other.go new file mode 100644 index 000000000..6fe10fc56 --- /dev/null +++ b/services/core/internal/sandbox/providers/capacity_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package providers + +import ( + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func hostCapacity(sandbox.Resources) error { return errors.New("sandbox nodes require Linux") } diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go new file mode 100644 index 000000000..51f2b11d6 --- /dev/null +++ b/services/core/internal/sandbox/providers/config.go @@ -0,0 +1,96 @@ +// Node-local adapter configuration and construction. +package providers + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +type Config struct { + Specification sandbox.DeploymentSpec `json:"specification"` + Generation uint64 `json:"generation"` + CoreURL string `json:"core_url"` + Provider string `json:"provider"` + InstallationID string `json:"installation_id"` + Docker *Docker `json:"docker,omitempty"` + Microsandbox *Microsandbox `json:"microsandbox,omitempty"` +} + +type Docker struct { + Host string `json:"host"` + Image string `json:"image"` + Network string `json:"network"` + SeccompFile string `json:"seccomp_file"` + ExtraHosts []string `json:"extra_hosts"` + NestedSandbox bool `json:"nested_sandbox"` +} + +// Load rejects unknown fields, mixed adapters and explicit null configuration. +func Load(file string) (Config, error) { + var config Config + raw, err := os.ReadFile(file) + if err != nil { + return config, errors.New("cannot read sandbox configuration") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { + return config, errors.New("invalid sandbox configuration") + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return config, errors.New("invalid sandbox configuration") + } + _, docker := fields["docker"] + _, micro := fields["microsandbox"] + if docker && micro { + return config, errors.New("only one sandbox provider can be configured") + } + return config, nil +} + +type Built struct { + SpecificationDigest string + Provider sandbox.SandboxProvider + InstallationID, BackendFingerprint string + Probe func(context.Context) error +} + +func Build(config Config) (*Built, func(), error) { + closeProvider := func() {} + if err := validateSpecification(config); err != nil { + return nil, closeProvider, err + } + id, err := uuid.Parse(config.InstallationID) + if err != nil || id == uuid.Nil || id.String() != config.InstallationID { + return nil, closeProvider, errors.New("sandbox requires a canonical installation_id UUID") + } + adapter, err := Lookup(config.Provider) + if err != nil || adapter.BuildLocal == nil { + return nil, closeProvider, errors.New("sandbox provider is not node-local") + } + result := &Built{InstallationID: config.InstallationID, SpecificationDigest: config.Specification.Digest(config.Provider)} + closeProvider, err = adapter.BuildLocal(config, result) + if err != nil { + return nil, closeProvider, err + } + if err := ValidateBinding(adapter, result.Provider); err != nil { + closeProvider() + return nil, func() {}, err + } + return result, closeProvider, nil +} + +func BackendFingerprint(kind, namespace string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + namespace)) + return hex.EncodeToString(digest[:]) +} diff --git a/services/agents-api/internal/sandbox/providers/config_test.go b/services/core/internal/sandbox/providers/config_test.go similarity index 97% rename from services/agents-api/internal/sandbox/providers/config_test.go rename to services/core/internal/sandbox/providers/config_test.go index ebc248620..2426b4538 100644 --- a/services/agents-api/internal/sandbox/providers/config_test.go +++ b/services/core/internal/sandbox/providers/config_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go similarity index 92% rename from services/agents-api/internal/sandbox/providers/deployment_contract_test.go rename to services/core/internal/sandbox/providers/deployment_contract_test.go index c016a356a..a3cd57f20 100644 --- a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -3,7 +3,7 @@ package providers import ( "bytes" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "os" "strings" "testing" @@ -16,7 +16,7 @@ func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { } expected := PythonDeploymentContract() if !strings.Contains(string(raw), expected) { - t.Fatal("node_spec.py contract is stale; regenerate with go run ./services/agents-api/cmd/specification-contract -write") + t.Fatal("node_spec.py contract is stale; regenerate with go run ./services/core/cmd/specification-contract -write") } } func TestDeploymentContractFixtures(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/providers/docker.go b/services/core/internal/sandbox/providers/docker.go similarity index 95% rename from services/agents-api/internal/sandbox/providers/docker.go rename to services/core/internal/sandbox/providers/docker.go index b6657b6f3..49512ed40 100644 --- a/services/agents-api/internal/sandbox/providers/docker.go +++ b/services/core/internal/sandbox/providers/docker.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" - sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + sandboxdocker "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" "github.com/moby/moby/client" ) diff --git a/services/agents-api/internal/sandbox/providers/e2b.go b/services/core/internal/sandbox/providers/e2b.go similarity index 91% rename from services/agents-api/internal/sandbox/providers/e2b.go rename to services/core/internal/sandbox/providers/e2b.go index 8f7b4f54c..8d52d27f3 100644 --- a/services/agents-api/internal/sandbox/providers/e2b.go +++ b/services/core/internal/sandbox/providers/e2b.go @@ -4,8 +4,8 @@ import ( "errors" "os" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" ) type DirectConfig struct { diff --git a/services/agents-api/internal/sandbox/providers/microsandbox.go b/services/core/internal/sandbox/providers/microsandbox.go similarity index 95% rename from services/agents-api/internal/sandbox/providers/microsandbox.go rename to services/core/internal/sandbox/providers/microsandbox.go index ddf587944..cf393379f 100644 --- a/services/agents-api/internal/sandbox/providers/microsandbox.go +++ b/services/core/internal/sandbox/providers/microsandbox.go @@ -4,8 +4,8 @@ import ( "errors" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - sandboxmicro "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" ) // Single-host providers pin the helper, runtime, firmware, image and resource diff --git a/services/core/internal/sandbox/providers/operations.go b/services/core/internal/sandbox/providers/operations.go new file mode 100644 index 000000000..566f8d040 --- /dev/null +++ b/services/core/internal/sandbox/providers/operations.go @@ -0,0 +1,18 @@ +package providers + +import ( + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "reflect" +) + +// ValidateBinding catches construction that disagrees with its registration. +func ValidateBinding(adapter Adapter, provider sandbox.SandboxProvider) error { + if err := sandbox.ValidateProvider(provider); err != nil { + return err + } + if adapter.Operations == nil || !reflect.DeepEqual(adapter.Operations(), provider.ProviderOperations()) { + return providercontract.ErrContract + } + return nil +} diff --git a/services/core/internal/sandbox/providers/operations_test.go b/services/core/internal/sandbox/providers/operations_test.go new file mode 100644 index 000000000..8f4455c42 --- /dev/null +++ b/services/core/internal/sandbox/providers/operations_test.go @@ -0,0 +1,22 @@ +package providers + +import ( + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "testing" +) + +func TestRegistrationRejectsMissingAndMismatchedDeclarations(t *testing.T) { + for _, adapter := range []Adapter{{}, {Operations: func() providercontract.Operations { return nil }}} { + adapters["invalid-contract-fixture"] = adapter + if _, err := Lookup("invalid-contract-fixture"); err == nil { + t.Fatal("invalid declaration registered") + } + } + delete(adapters, "invalid-contract-fixture") + if err := ValidateBinding(Adapter{Operations: e2b.Operations}, &docker.Provider{}); !errors.Is(err, providercontract.ErrContract) { + t.Fatal("registration differs from instance", err) + } +} diff --git a/services/core/internal/sandbox/providers/probe.go b/services/core/internal/sandbox/providers/probe.go new file mode 100644 index 000000000..805958797 --- /dev/null +++ b/services/core/internal/sandbox/providers/probe.go @@ -0,0 +1,113 @@ +package providers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "runtime" + "sync" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/moby/moby/client" +) + +// Probes report the first failed check. Precedence runs from the provider +// platform (Docker daemon or KVM), through Docker limit support and host +// capacity for one sandbox of the deployment specification, to the installed +// Runtime content (image or microsandbox artifacts). Unclassified failures stay +// provider_unavailable. The returned text is local; only its code is reported. + +// kvmDevice is replaceable only by tests. +var kvmDevice = "/dev/kvm" + +func dockerProbe(c *client.Client, image string, resources sandbox.Resources) func(context.Context) error { + return func(ctx context.Context) error { + if _, err := c.Ping(ctx, client.PingOptions{}); err != nil { + return sandbox.ErrDockerUnavailable + } + host, err := c.Info(ctx, client.InfoOptions{}) + if err != nil { + return fmt.Errorf("%w: cannot inspect Docker host resource support", sandbox.ErrDockerUnavailable) + } + if !host.Info.MemoryLimit || !host.Info.CPUCfsQuota { + return sandbox.ErrDockerLimitsUnsupported + } + if host.Info.MemTotal <= 0 { + return errors.New("Docker host memory capacity is unavailable") + } + if err := checkCapacity(resources, host.Info.NCPU, uint64(host.Info.MemTotal)); err != nil { + return err + } + if _, err = c.ImageInspect(ctx, image); errdefs.IsNotFound(err) { + return sandbox.ErrRuntimeImageUnavailable + } else if err != nil { + // The daemon did not answer; the image may still be present. + return fmt.Errorf("%w: cannot inspect the pinned Runtime image", sandbox.ErrDockerUnavailable) + } + return nil + } +} + +// A successful Runtime integrity check is cached for this immutable +// configuration. A failure is checked again on the next heartbeat, so repaired +// artifacts recover without a restart. Lifecycle calls still verify the exact +// artifact themselves. +func microsandboxProbe(entry Microsandbox, resources sandbox.Resources) func(context.Context) error { + var integrity sync.Mutex + verified := false + return func(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if runtime.GOOS != "linux" { + return fmt.Errorf("%w: microsandbox requires a Linux KVM node", sandbox.ErrKVMUnavailable) + } + kvm, err := os.OpenFile(kvmDevice, os.O_RDWR, 0) + if err != nil { + return sandbox.ErrKVMUnavailable + } + _ = kvm.Close() + if err := hostCapacity(resources); err != nil { + return err + } + integrity.Lock() + if !verified { + if err := verifyMicrosandboxArtifacts(entry); err != nil { + integrity.Unlock() + return err + } + verified = true + } + integrity.Unlock() + helper, err := os.Stat(entry.HelperPath) + if err != nil || !helper.Mode().IsRegular() || helper.Mode().Perm()&0111 == 0 { + return fmt.Errorf("%w: microsandbox helper is unavailable", sandbox.ErrMicrosandboxArtifactsUnavailable) + } + home, err := os.Lstat(entry.RuntimeHome) + if err != nil || !home.IsDir() || home.Mode().Perm() != 0700 { + return errors.New("microsandbox state directory is unavailable") + } + return nil + } +} + +func verifyMicrosandboxArtifacts(entry Microsandbox) error { + for _, artifact := range []struct{ path, hash string }{{entry.RuntimePath, entry.RuntimeSHA256}, {entry.FirmwarePath, entry.FirmwareSHA256}} { + f, err := os.Open(artifact.path) + if err != nil { + return sandbox.ErrMicrosandboxArtifactsUnavailable + } + h := sha256.New() + _, err = io.Copy(h, f) + _ = f.Close() + if err != nil || hex.EncodeToString(h.Sum(nil)) != artifact.hash { + return fmt.Errorf("%w: artifact integrity check failed", sandbox.ErrMicrosandboxArtifactsUnavailable) + } + } + return nil +} diff --git a/services/agents-api/internal/sandbox/providers/probe_test.go b/services/core/internal/sandbox/providers/probe_test.go similarity index 99% rename from services/agents-api/internal/sandbox/providers/probe_test.go rename to services/core/internal/sandbox/providers/probe_test.go index e226b5b79..e8c25f02e 100644 --- a/services/agents-api/internal/sandbox/providers/probe_test.go +++ b/services/core/internal/sandbox/providers/probe_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/moby/moby/client" ) diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go new file mode 100644 index 000000000..362feff01 --- /dev/null +++ b/services/core/internal/sandbox/providers/registry.go @@ -0,0 +1,177 @@ +// Package providers is the explicit registration boundary for sandbox adapters. +// It performs read-only configuration work; it never allocates compute. +package providers + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" +) + +// Adapter describes configuration and transport independently of compute operations. +// Native operation support comes from the adapter-owned complete declaration. +type Adapter struct { + Policy sandbox.DeploymentPolicy + ReplaceCredential func(sandbox.Selection, sandbox.Selection) sandbox.Selection + CredentialRequiresReset func(error) bool + CredentialUnconfirmed error + Restore func(sandbox.Selection) (sandbox.Selection, error) + ResolveChange func(sandbox.Selection, sandbox.Selection) sandbox.Selection + BuildLocal func(Config, *Built) (func(), error) + BuildDirect func(DirectConfig) (sandbox.SandboxProvider, error) + Credential bool + PublicOrigin bool + Mode string + Operations func() providercontract.Operations + IdleSeconds, RetentionSeconds int64 + ValidateSpecification func(sandbox.DeploymentSpec) error + ValidateResources func(sandbox.Resources) error + Normalize func(sandbox.Selection) (sandbox.Selection, error) +} + +var adapters = map[string]Adapter{ + "docker": { + Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: buildDocker, + ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, + Normalize: nodeSelection(docker.ValidateSpecification), + }, + "microsandbox": { + Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, + IdleSeconds: 300, RetentionSeconds: 86400, + ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, + Normalize: nodeSelection(microsandbox.ValidateSpecification), + }, + "e2b": { + Policy: e2b.Policy(), Operations: e2b.Operations, Mode: "direct", BuildDirect: buildE2B, + Credential: true, PublicOrigin: true, + ReplaceCredential: e2b.ReplaceCredential, CredentialRequiresReset: e2b.CredentialRequiresReset, + CredentialUnconfirmed: e2b.ErrRequestUnconfirmed, Restore: e2b.RestoreSelection, + ResolveChange: e2b.ResolveChange, Normalize: e2b.NormalizeSelection, + ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, + }, +} + +func Lookup(kind string) (Adapter, error) { + a, ok := adapters[kind] + if !ok || a.Operations == nil { + return Adapter{}, fmt.Errorf("%w: unsupported sandbox provider", sandbox.ErrInvalid) + } + if err := sandbox.ValidateOperations(a.Operations()); err != nil { + return Adapter{}, err + } + return a, nil +} +func IsNode(kind string) bool { a, e := Lookup(kind); return e == nil && a.Mode == "nodes" } +func SupportsCheckpoint(kind string) bool { + a, e := Lookup(kind) + return e == nil && a.Operations()["Initial"].State == providercontract.Supported +} + +// RetainedLimit keeps nodes without checkpoint support within their active capacity. +func RetainedLimit(kind string, active, retained int) int { + a, err := Lookup(kind) + if err == nil && a.Mode == "nodes" && !SupportsCheckpoint(kind) { + return active + } + return retained +} + +func ValidateSpecification(kind string, s sandbox.DeploymentSpec) error { + a, e := Lookup(kind) + if e != nil { + return e + } + return a.ValidateSpecification(s) +} +func ValidateResources(kind string, s sandbox.Resources) error { + a, e := Lookup(kind) + if e != nil { + return e + } + return a.ValidateResources(s) +} +func Normalize(s sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(s.Provider) + if e != nil { + return s, e + } + return a.Normalize(s) +} +func nodeSelection(validate func(sandbox.DeploymentSpec) error) func(sandbox.Selection) (sandbox.Selection, error) { + return func(s sandbox.Selection) (sandbox.Selection, error) { + if s.E2B != nil { + return s, sandbox.ErrInvalid + } + return s, validate(s.DeploymentSpec) + } +} + +// Description is derived once for both preview and persistence. Its fingerprint +// identifies a namespace, never mutable capacity or a credential. +type Description struct { + Mode, BackendFingerprint string + IdleSeconds, RetentionSeconds int64 +} + +func Describe(kind, installation string) (Description, error) { + a, e := Lookup(kind) + if e != nil { + return Description{}, e + } + namespace := a.Mode + if a.Mode == "direct" { + namespace = kind + } + digest := sha256.Sum256([]byte(kind + "\x00" + namespace + ":" + installation)) + return Description{a.Mode, hex.EncodeToString(digest[:]), a.IdleSeconds, a.RetentionSeconds}, nil +} + +func UsesCredential(kind string) bool { a, e := Lookup(kind); return e == nil && a.Credential } +func Restore(s sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(s.Provider) + if e != nil { + return s, e + } + if a.Restore != nil { + return a.Restore(s) + } + s.E2B = nil + return s, nil +} +func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(next.Provider) + if e != nil { + return next, e + } + if a.ResolveChange != nil { + next = a.ResolveChange(next, previous) + } + return Normalize(next) +} + +func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { + a, e := Lookup(owner.Provider) + if e != nil { + return owner, e + } + if a.ReplaceCredential == nil { + return owner, sandbox.ErrInvalid + } + return a.ReplaceCredential(owner, candidate), nil +} + +// PythonDeploymentContract projects the same registered adapter policies into +// the node installer; no second provider list exists in another language. +func PythonDeploymentContract() string { + policies := make(map[string]sandbox.DeploymentPolicy, len(adapters)) + for kind, a := range adapters { + policies[kind] = a.Policy + } + return sandbox.PythonDeploymentContract(policies) +} diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go new file mode 100644 index 000000000..b7cab2729 --- /dev/null +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -0,0 +1,102 @@ +package providers + +import ( + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { + installation := uuid.NewString() + for _, tc := range []struct { + kind, mode, namespace string + idle, retention int64 + checkpoint bool + }{ + {"docker", "nodes", "nodes", 0, 0, false}, + {"microsandbox", "nodes", "nodes", 300, 86400, true}, + {"e2b", "direct", "e2b", 0, 0, false}, + } { + t.Run(tc.kind, func(t *testing.T) { + d, err := Describe(tc.kind, installation) + if err != nil || d.Mode != tc.mode || d.IdleSeconds != tc.idle || d.RetentionSeconds != tc.retention || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + t.Fatalf("wrong namespace or defaults: %+v %v", d, err) + } + a, err := Lookup(tc.kind) + if err != nil || SupportsCheckpoint(tc.kind) != tc.checkpoint || IsNode(tc.kind) != (tc.mode == "nodes") || (a.BuildLocal != nil) != (tc.mode == "nodes") || (a.BuildDirect != nil) != (tc.mode == "direct") { + t.Fatal("inconsistent construction/capability registration", err) + } + }) + } + if _, err := Describe("unregistered", installation); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("unknown kind accepted", err) + } +} + +func TestSelectionNormalizationAndCredentialInheritance(t *testing.T) { + input := sandbox.Selection{Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "original-key", Template: "runtime:" + uuid.NewString()}} + normalized, err := Normalize(input) + if err != nil || normalized.E2B.APIURL != "https://api.e2b.app" || normalized.E2B.Domain != "e2b.app" { + t.Fatal("defaults not normalized", err) + } + if input.E2B.APIURL != "" || input.E2B.Domain != "" { + t.Fatal("normalization changed request") + } + normalized.Resources = sandbox.Resources{CPUs: 4, MemoryMiB: 4096} + request := input + request.E2B = &sandbox.E2BConfiguration{Template: input.E2B.Template} + next, err := ResolveChange(request, normalized) + if err != nil || next.Resources != normalized.Resources || next.E2B.APIKey != "original-key" || next.E2B.APIURL != normalized.E2B.APIURL || next.ReplacesCredential() { + t.Fatal("omitted values lost committed selection", err) + } + request.E2B.ReplaceCredential = true + if _, err := ResolveChange(request, normalized); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("explicit empty credential silently inherited", err) + } + if request.E2B.APIKey != "" || request.Resources != (sandbox.Resources{}) { + t.Fatal("resolution changed request") + } +} + +func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { + const kind = "contract-test-provider" + // Registration is test-local: production registrations are fixed, never plugins. + adapters[kind] = Adapter{Mode: "nodes", Operations: docker.Operations, ValidateSpecification: func(sandbox.DeploymentSpec) error { return nil }, Normalize: nodeSelection(func(sandbox.DeploymentSpec) error { return nil })} + defer delete(adapters, kind) + s, err := Normalize(sandbox.Selection{Provider: kind}) + if err != nil || s.Provider != kind || !IsNode(kind) || SupportsCheckpoint(kind) { + t.Fatal("new entry did not follow shared boundary", err) + } + d, err := Describe(kind, uuid.NewString()) + if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { + t.Fatal(d, err) + } + if _, err := Normalize(sandbox.Selection{Provider: kind, E2B: &sandbox.E2BConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("mixed configuration admitted", err) + } +} + +func TestRetainedLimitUsesRegisteredCapabilities(t *testing.T) { + const kind = "capacity-test-provider" + defer delete(adapters, kind) + for _, checkpoint := range []bool{false, true} { + operations := docker.Operations + if checkpoint { + operations = microsandbox.Operations + } + adapters[kind] = Adapter{Mode: "nodes", Operations: operations} + for _, retained := range []int{0, 20} { + want := 10 + if checkpoint { + want = retained + } + if got := RetainedLimit(kind, 10, retained); got != want { + t.Fatalf("checkpoint=%v retained=%d: got %d, want %d", checkpoint, retained, got, want) + } + } + } +} diff --git a/services/agents-api/internal/sandbox/providers/specification.go b/services/core/internal/sandbox/providers/specification.go similarity index 100% rename from services/agents-api/internal/sandbox/providers/specification.go rename to services/core/internal/sandbox/providers/specification.go diff --git a/services/agents-api/internal/sandbox/providers/validation_test.go b/services/core/internal/sandbox/providers/validation_test.go similarity index 97% rename from services/agents-api/internal/sandbox/providers/validation_test.go rename to services/core/internal/sandbox/providers/validation_test.go index 6810d4d84..7fb0eff9c 100644 --- a/services/agents-api/internal/sandbox/providers/validation_test.go +++ b/services/core/internal/sandbox/providers/validation_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { diff --git a/services/agents-api/internal/sandbox/runtime_bootstrap.go b/services/core/internal/sandbox/runtime_bootstrap.go similarity index 84% rename from services/agents-api/internal/sandbox/runtime_bootstrap.go rename to services/core/internal/sandbox/runtime_bootstrap.go index 36d4a1ba7..a4a05ecad 100644 --- a/services/agents-api/internal/sandbox/runtime_bootstrap.go +++ b/services/core/internal/sandbox/runtime_bootstrap.go @@ -1,6 +1,6 @@ package sandbox -import "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" +import "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" // RuntimeConnection projects provider allocation input into Runtime's public // startup contract. Providers must never construct a private auth profile. diff --git a/services/agents-api/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go similarity index 98% rename from services/agents-api/internal/sandbox/sandbox_provider.go rename to services/core/internal/sandbox/sandbox_provider.go index 170e402bb..7af67ff43 100644 --- a/services/agents-api/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -21,7 +21,7 @@ package sandbox import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" ) var ( diff --git a/services/agents-api/internal/sandbox/selection.go b/services/core/internal/sandbox/selection.go similarity index 100% rename from services/agents-api/internal/sandbox/selection.go rename to services/core/internal/sandbox/selection.go diff --git a/services/agents-api/internal/sandbox/suspension.go b/services/core/internal/sandbox/suspension.go similarity index 100% rename from services/agents-api/internal/sandbox/suspension.go rename to services/core/internal/sandbox/suspension.go diff --git a/services/agents-api/internal/sandbox/testdata/deployment-contract.json b/services/core/internal/sandbox/testdata/deployment-contract.json similarity index 100% rename from services/agents-api/internal/sandbox/testdata/deployment-contract.json rename to services/core/internal/sandbox/testdata/deployment-contract.json diff --git a/services/agents-api/internal/store/admin_audit.go b/services/core/internal/store/admin_audit.go similarity index 94% rename from services/agents-api/internal/store/admin_audit.go rename to services/core/internal/store/admin_audit.go index 9b9570a68..bc4b08acb 100644 --- a/services/agents-api/internal/store/admin_audit.go +++ b/services/core/internal/store/admin_audit.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/admin_delete_audit_test.go b/services/core/internal/store/admin_delete_audit_test.go similarity index 98% rename from services/agents-api/internal/store/admin_delete_audit_test.go rename to services/core/internal/store/admin_delete_audit_test.go index b37336660..6d7f94cd9 100644 --- a/services/agents-api/internal/store/admin_delete_audit_test.go +++ b/services/core/internal/store/admin_delete_audit_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/core/internal/store/admin_history.go b/services/core/internal/store/admin_history.go new file mode 100644 index 000000000..e98f0ca2e --- /dev/null +++ b/services/core/internal/store/admin_history.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type AdminAuditFilter struct { + ProjectID, ResourceType, ResourceID, Action, After string + CreatedAfter, CreatedBefore *time.Time + Limit int +} + +// AdminAuditOperation is one administrator write. ProjectID is null for +// deployment-wide writes, such as deployment default model providers. +type AdminAuditOperation struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + AdminCredentialID string `json:"admin_credential_id"` + ActorLabel string `json:"actor_label"` + Action string `json:"action"` + ProjectID *string `json:"project_id" extensions:"x-nullable"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ResultIDs json.RawMessage `json:"result_ids" swaggertype:"array,object"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` +} +type AdminAuditPage struct { + Data []AdminAuditOperation `json:"data"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + +func (s *Store) ListAdminAudit(ctx context.Context, filter AdminAuditFilter) (AdminAuditPage, error) { + page := AdminAuditPage{Data: []AdminAuditOperation{}} + if filter.Limit == 0 { + filter.Limit = 50 + } + if filter.Limit < 1 || filter.Limit > 100 || !auditText(filter.ProjectID, 128, false) || !auditText(filter.ResourceType, 64, false) || !auditText(filter.ResourceID, 256, false) || !auditText(filter.Action, 64, false) || filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { + return page, ErrInvalidInput + } + normalized := filter + normalized.After = "" + normalized.Limit = 0 + if normalized.CreatedAfter != nil { + v := normalized.CreatedAfter.UTC() + normalized.CreatedAfter = &v + } + if normalized.CreatedBefore != nil { + v := normalized.CreatedBefore.UTC() + normalized.CreatedBefore = &v + } + raw, _ := json.Marshal(normalized) + digest := sha256.Sum256(raw) + scope := hex.EncodeToString(digest[:]) + params := sqlc.ListAdminAuditLogParams{ProjectID: filter.ProjectID, ResourceType: filter.ResourceType, ResourceID: filter.ResourceID, Action: filter.Action, CreatedAfter: auditTimestamp(filter.CreatedAfter), CreatedBefore: auditTimestamp(filter.CreatedBefore), AfterID: pgtype.UUID{Valid: true}, PageLimit: int32(filter.Limit + 1)} + if filter.After != "" { + raw, err := base64.RawURLEncoding.DecodeString(filter.After) + var cursor writeAuditCursor + if len(filter.After) > 1024 || err != nil || json.Unmarshal(raw, &cursor) != nil || cursor.Scope != scope { + return page, ErrInvalidInput + } + params.AfterID, err = parseID(cursor.ID) + if err != nil { + return page, ErrInvalidInput + } + params.AfterTime, err = s.queries.AdminAuditCursor(ctx, params.AfterID) + if errors.Is(err, pgx.ErrNoRows) { + return page, ErrInvalidInput + } + if err != nil { + return page, err + } + } + rows, err := s.queries.ListAdminAuditLog(ctx, params) + if err != nil { + return page, err + } + page.HasMore = len(rows) > filter.Limit + if page.HasMore { + rows = rows[:filter.Limit] + } + for _, row := range rows { + page.Data = append(page.Data, AdminAuditOperation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: auditProjectID(row.ProjectID), ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResultIDs: row.ResultIds, RequestID: row.RequestID, TraceID: row.TraceID}) + } + if page.HasMore { + raw, _ := json.Marshal(writeAuditCursor{ID: page.Data[len(page.Data)-1].ID, Scope: scope}) + page.NextCursor = base64.RawURLEncoding.EncodeToString(raw) + } + return page, nil +} + +func auditProjectID(id pgtype.UUID) *string { + if !id.Valid { + return nil + } + value := uuid.UUID(id.Bytes).String() + return &value +} diff --git a/services/agents-api/internal/store/admin_key_audit_test.go b/services/core/internal/store/admin_key_audit_test.go similarity index 98% rename from services/agents-api/internal/store/admin_key_audit_test.go rename to services/core/internal/store/admin_key_audit_test.go index 2dff6244c..6a615d1d6 100644 --- a/services/agents-api/internal/store/admin_key_audit_test.go +++ b/services/core/internal/store/admin_key_audit_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/google/uuid" ) diff --git a/services/core/internal/store/admin_session_archive.go b/services/core/internal/store/admin_session_archive.go new file mode 100644 index 000000000..f0dbc0f8a --- /dev/null +++ b/services/core/internal/store/admin_session_archive.go @@ -0,0 +1,160 @@ +package store + +import ( + "context" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// ManagedSessionArchive reports resource disposal, not archive request provenance +// or Turn settlement. Existing expiry and failed provisioning use the same states. +type ManagedSessionArchive struct { + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + State string `json:"state"` +} + +// ArchiveManagedSession ends a managed Environment's lifetime while keeping its +// public Session, history and persisted files. The lifecycle owner performs the +// external cleanup; only its existing confirmation can release an allocation. +func (s *Store) ArchiveManagedSession(ctx context.Context, tenantID, sessionID string, expectedGeneration uint64) (ManagedSessionArchive, error) { + return s.archiveManagedSession(ctx, tenantID, sessionID, expectedGeneration, nil) +} + +// A reset instance is identified by its persisted request time as well as its +// generation, preventing a cancelled clear's candidates from affecting its successor. +func (s *Store) ArchiveSandboxResetSession(ctx context.Context, tenantID, sessionID string, generation uint64, requestedAt time.Time) (ManagedSessionArchive, error) { + return s.archiveManagedSession(ctx, tenantID, sessionID, generation, &requestedAt) +} + +var ErrSandboxResetSessionBusy = errors.New("the hosted Session is busy") + +func (s *Store) archiveManagedSession(ctx context.Context, tenantID, sessionID string, expectedGeneration uint64, resetRequestedAt *time.Time) (ManagedSessionArchive, error) { + if s.executionLease == nil { + return ManagedSessionArchive{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return ManagedSessionArchive{}, err + } + var result ManagedSessionArchive + err = s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + // Session precedes deployment, matching Turn, allocation and input admission. + deployment, err := q.LockRuntimeDeployment(ctx) + if err != nil { + return err + } + if uint64(deployment.Generation) != expectedGeneration { + return &SandboxGenerationStaleError{uint64(deployment.Generation)} + } + if !deployment.WebManaged || !deployment.InstallationID.Valid { + return ErrSandboxDeploymentConflict + } + if deployment.ProviderKind == "" { + return ErrSandboxNotConfigured + } + environment, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrInvalidInput + } + if err != nil { + return err + } + kind, err := storedEnvironmentType(environment) + if err != nil || kind != "openai_hosted" { + return ErrInvalidInput + } + if resetRequestedAt != nil { + if !deployment.ResetClear.Valid || !deployment.ResetRequestedAt.Time.Equal(*resetRequestedAt) { + return ErrSandboxDeploymentConflict + } + if deployment.ResetClear.String == "auto" { + busy, err := q.SessionBlocksAutoReset(ctx, session) + if err != nil { + return err + } + if busy { + return ErrSandboxResetSessionBusy + } + } + if environment.Environment.Status == "failed" || environment.Environment.Status == "expired" { + result, err = getManagedSessionArchive(ctx, q, tenant, session) + return err + } + source, err := sandboxResetAudit(deployment) + if err != nil { + return err + } + project, err := q.GetSandboxResetProject(ctx, tenant) + if err != nil { + return err + } + source.ProjectID = runtimeUUID(project) + ctx = adminaudit.WithSource(ctx, source) + } + allocation, err := q.GetRuntimeAllocation(ctx, sqlc.GetRuntimeAllocationParams{TenantID: tenant, EnvironmentID: environment.Environment.ID}) + allocated := err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if allocated && allocation.RuntimeAllocation.State != "released" && allocation.RuntimeAllocation.ProviderKey != deployment.InstallationID { + return ErrSandboxDeploymentConflict + } + if err := withEnvironmentInputActivity(ctx, q, session, func() error { + if environment.Environment.Status != "failed" && environment.Environment.Status != "expired" { + if err := q.SetEnvironmentConnectionStatus(ctx, sqlc.SetEnvironmentConnectionStatusParams{ID: environment.Environment.ID, Status: "expired"}); err != nil { + return err + } + } + return cancelSessionWork(ctx, q, session) + }); err != nil { + return err + } + if allocated && allocation.RuntimeAllocation.State != "released" { + if _, err := q.RevokeArchivedRuntimeDevice(ctx, sqlc.RevokeArchivedRuntimeDeviceParams{TenantID: tenant, DeviceID: allocation.RuntimeAllocation.DeviceID, SessionID: session}); err != nil { + return err + } + if _, err := q.RequestRuntimeCleanup(ctx, allocation.RuntimeAllocation.ID); err != nil { + return err + } + } else if !allocated { + if err := q.ReleaseUnallocatedRuntimePlacement(ctx, session); err != nil { + return err + } + } + if err := recordAdminMutation(ctx, q, tenantID, "archive", "session", runtimeUUID(session)); err != nil { + return err + } + result, err = getManagedSessionArchive(ctx, q, tenant, session) + return err + }) + return result, err +} + +// GetManagedSessionArchive reads one database snapshot and never contacts compute. +func (s *Store) GetManagedSessionArchive(ctx context.Context, tenantID, sessionID string) (ManagedSessionArchive, error) { + tenant, err := parseID(tenantID) + if err != nil { + return ManagedSessionArchive{}, err + } + return getManagedSessionArchive(ctx, s.queries, tenant, parsePathID(sessionID)) +} + +func getManagedSessionArchive(ctx context.Context, q *sqlc.Queries, tenant, session pgtype.UUID) (ManagedSessionArchive, error) { + row, err := q.GetManagedSessionArchive(ctx, sqlc.GetManagedSessionArchiveParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ManagedSessionArchive{}, ErrNotFound + } + if err != nil { + return ManagedSessionArchive{}, err + } + if row.EnvironmentType != "openai_hosted" { + return ManagedSessionArchive{}, ErrInvalidInput + } + return ManagedSessionArchive{SessionID: runtimeUUID(row.SessionID), EnvironmentID: runtimeUUID(row.EnvironmentID), State: row.State}, nil +} diff --git a/services/agents-api/internal/store/admin_session_archive_race_test.go b/services/core/internal/store/admin_session_archive_race_test.go similarity index 100% rename from services/agents-api/internal/store/admin_session_archive_race_test.go rename to services/core/internal/store/admin_session_archive_race_test.go diff --git a/services/core/internal/store/admin_session_archive_test.go b/services/core/internal/store/admin_session_archive_test.go new file mode 100644 index 000000000..9f6137675 --- /dev/null +++ b/services/core/internal/store/admin_session_archive_test.go @@ -0,0 +1,258 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func managedArchiveFixture(t *testing.T) (*Store, *Store, string) { + t.Helper() + _, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + installation := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { + t.Fatal(err) + } + if _, err := w.InitializeSandboxDeployment(t.Context(), installation, e2bSelection()); err != nil { + t.Fatal(err) + } + return s, w, installation +} + +func managedArchiveSession(t *testing.T, s *Store, input CreateSessionInput) (string, Session) { + t.Helper() + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'admin-archive',$2)", tenant, tenant); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Archive fixture',$1,'service_account',$2)", tenant, "project:"+tenant); err != nil { + t.Fatal(err) + } + return tenant, session +} + +func archiveAllocation(t *testing.T, w *Store, tenant string, session Session, installation string) RuntimeAllocation { + t.Helper() + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + return owner +} + +func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + input := managerSessionInput(uuid.NewString()) + input.InitialInputs = []Input{{Kind: "message", Payload: json.RawMessage(`{"text":"waiting"}`)}} + tenant, session := managedArchiveSession(t, s, input) + ctx := adminDeleteContext(t.Context(), tenant, uuid.NewString()) + active, err := s.GetManagedSessionArchive(t.Context(), tenant, session.ID) + if err != nil || active.State != "active" || active.SessionID != session.ID || active.EnvironmentID != session.Environment.ID { + t.Fatal("unallocated Session status", active, err) + } + for _, generation := range []uint64{0, 2, ^uint64(0)} { + if _, err := w.ArchiveManagedSession(ctx, tenant, session.ID, generation); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("archive accepted wrong generation", generation, err) + } + } + if _, err := s.ArchiveManagedSession(ctx, tenant, session.ID, 1); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unleased archive accepted", err) + } + for _, other := range []string{uuid.NewString(), "malformed"} { + if _, err := w.ArchiveManagedSession(ctx, tenant, other, 1); !errors.Is(err, ErrNotFound) { + t.Fatal("unknown archive", err) + } + if _, err := s.GetManagedSessionArchive(ctx, tenant, other); !errors.Is(err, ErrNotFound) { + t.Fatal("unknown status", err) + } + } + if _, err := w.ArchiveManagedSession(ctx, uuid.NewString(), session.ID, 1); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign archive", err) + } + if _, err := s.GetManagedSessionArchive(ctx, uuid.NewString(), session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign status", err) + } + result, err := w.ArchiveManagedSession(ctx, tenant, session.ID, 1) + if err != nil || result.State != "released" { + t.Fatal("unallocated archive", result, err) + } + row, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || row.Environment.Status != "expired" || row.EnvironmentFailure != nil || row.PendingInput || row.EnvironmentInputActivity != nil || row.LastTurn != nil { + t.Fatal("archive fabricated failed execution", row, err) + } + var reservationState string + if err := s.pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE session_id=$1", session.ID).Scan(&reservationState); err != nil || reservationState != "cancelled" { + t.Fatal("archive left pending initial input", reservationState, err) + } + before := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events") + retry, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1) + if err != nil || retry != result || !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events")) { + t.Fatal("archive retry changed Session history", retry, err) + } + if status, err := s.GetManagedSessionArchive(ctx, tenant, session.ID); err != nil || status != result { + t.Fatal("status differs from committed archive", status, err) + } + if _, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())); !errors.Is(err, ErrInvalidInput) { + t.Fatal("archived Environment allocated after archive", err) + } + if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatal("archived Environment accepted new input", err) + } + view, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || view.Resources.Pending != 0 || view.Resources.Allocations != 0 { + t.Fatal("unallocated archive still blocks switching", view, err) + } +} + +func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + owner := archiveAllocation(t, w, tenant, session, installation) + file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource([]byte("retained source file"))) + if err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, session.ID, "completed") + transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + if err := w.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained"}`)}}); err != nil { + t.Fatal(err) + } + body := []byte("retained artifact") + if err := s.StageTurnArtifacts(t.Context(), tenant, session.ID, input.TurnID, session.Environment.ID, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/result.txt": body}))); err != nil { + t.Fatal(err) + } + transition(t, w, tenant, session.ID, input.TurnID, TurnInProgress, TurnCompleted) + history := adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata") + request := uuid.NewString() + result, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, request), tenant, session.ID, 1) + if err != nil || result.State != "cleanup_pending" { + t.Fatal(result, err) + } + assertAdminMutationAudit(t, s, tenant, request, "archive", "session", session.ID) + if !reflect.DeepEqual(history, adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata")) { + t.Fatal("archive changed persisted history or artifacts") + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || ok { + t.Fatal("archive retained runtime authority", err) + } + if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { + t.Fatal("archive discarded unknown Create ownership", err) + } + replay, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) + if err != nil || !replay.Replayed || replay.ID != owner.ID || replay.DeviceID != owner.DeviceID || replay.State != "cleanup_pending" { + t.Fatal("late provisioning retry replaced archived allocation", replay, err) + } + if _, err := w.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + current, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || current.EnvironmentFailure != nil || current.LastTurn == nil || current.LastTurn.Status != TurnCompleted || current.Environment.Status != "expired" { + t.Fatal("cleanup rewrote completed outcome", current, err) + } + if _, err := w.SettleRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if result, err := s.GetManagedSessionArchive(t.Context(), tenant, session.ID); err != nil || result.State != "released" { + t.Fatal("release not reflected", result, err) + } + page, err := s.ListSessionArtifacts(t.Context(), tenant, session.ID, "", "", 100, true) + if err != nil || len(page.Artifacts) != 1 { + t.Fatal(page, err) + } + if err := s.ReadSessionArtifact(t.Context(), tenant, session.ID, page.Artifacts[0].ID, func(_ SessionArtifact, r io.Reader) error { + got, err := io.ReadAll(r) + if !bytes.Equal(got, body) { + t.Error("archive damaged published artifact bytes") + } + return err + }); err != nil { + t.Fatal(err) + } + if err := s.ReadSourceFile(t.Context(), tenant, file.ID, func(_ SourceFile, r io.Reader) error { + got, err := io.ReadAll(r) + if string(got) != "retained source file" { + t.Error("archive damaged source file bytes") + } + return err + }); err != nil { + t.Fatal(err) + } +} + +func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + archiveAllocation(t, w, tenant, session, installation) + input := submitMessage(t, s, tenant, session.ID, "running") + transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + rejectAdminAuditInsert(t, s) + tables := []string{"sessions", "environments", "turns", "session_events", "devices", "runtime_allocations", "runtime_placements", "environment_input_reservations", "admin_audit_log"} + before := adminMutationSnapshot(t, s, tables...) + count := adminAuditRejections(t, s) + _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, rejectedAdminRequest), tenant, session.ID, 1) + requireAdminAuditFailure(t, s, err, count) + if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, tables...)) { + t.Fatal("failed audit retained archive, revocation or cancellation") + } + if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1); err != nil { + t.Fatal(err) + } + turn, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) + if err != nil || turn.Status != TurnInProgress || turn.CancelRequestedAt.IsZero() { + t.Fatal("archive did not request cancellation or fabricated settlement", turn, err) + } +} + +func TestManagedSessionArchivePreservesFailuresAndRejectsSelfHosted(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + owner := archiveAllocation(t, w, tenant, session, installation) + if _, err := s.pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID); err != nil { + t.Fatal(err) + } + if err := w.FailEnvironmentInitialization(t.Context(), EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}, ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { + t.Fatal(err) + } + failed, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || failed.EnvironmentFailure == nil { + t.Fatal("failure fixture", err) + } + otherTenant, selfHosted := managedArchiveSession(t, s, environmentInput(uuid.NewString(), "self_hosted", "/workspace")) + if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1); err != nil { + t.Fatal(err) + } + after, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(after.EnvironmentFailure, failed.EnvironmentFailure) || after.Environment.Status != "failed" { + t.Fatal("archive changed recorded provisioning failure", after, err) + } + before := adminMutationSnapshot(t, s, "sessions", "environments", "admin_audit_log") + if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), otherTenant, uuid.NewString()), otherTenant, selfHosted.ID, 1); !errors.Is(err, ErrInvalidInput) { + t.Fatal("self-hosted archive accepted", err) + } + if _, err := s.GetManagedSessionArchive(t.Context(), otherTenant, selfHosted.ID); !errors.Is(err, ErrInvalidInput) { + t.Fatal("self-hosted cleanup projected", err) + } + if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "sessions", "environments", "admin_audit_log")) { + t.Fatal("self-hosted archive changed resources") + } +} diff --git a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go b/services/core/internal/store/admin_session_archive_worker_http_test.go similarity index 92% rename from services/agents-api/internal/store/admin_session_archive_worker_http_test.go rename to services/core/internal/store/admin_session_archive_worker_http_test.go index 6b6c102c8..6e9401ffa 100644 --- a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go +++ b/services/core/internal/store/admin_session_archive_worker_http_test.go @@ -11,14 +11,14 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/admin_summary.go b/services/core/internal/store/admin_summary.go new file mode 100644 index 000000000..14a1c092e --- /dev/null +++ b/services/core/internal/store/admin_summary.go @@ -0,0 +1,122 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type AdminSummaryFilter struct{ CreatedAfter, CreatedBefore *time.Time } +type AdminAssetCounts struct { + Agents int64 `json:"agents"` + Skills int64 `json:"skills"` + EnvironmentTemplates int64 `json:"environment_templates"` + Files int64 `json:"files"` + Vaults int64 `json:"vaults"` + Credentials int64 `json:"credentials"` +} + +// ReadAdminSummary visits one space's Sessions from one read-only snapshot. The +// visitor reuses the API's Session projection instead of creating another status +// or usage model. Paging keeps the stored configurations out of an unbounded slice. +func (s *Store) ReadAdminSummary(ctx context.Context, tenantID string, filter AdminSummaryFilter, visit func(Session, *string) error) (AdminAssetCounts, error) { + var counts AdminAssetCounts + tenant, err := parseID(tenantID) + if err != nil { + return counts, err + } + if visit == nil || filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { + return counts, ErrInvalidInput + } + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + raw, err := q.AdminAssetCounts(ctx, tenant) + if err != nil { + return err + } + counts = AdminAssetCounts{Agents: raw.Agents, Skills: raw.Skills, EnvironmentTemplates: raw.EnvironmentTemplates, Files: raw.Files, Vaults: raw.Vaults, Credentials: raw.Credentials} + params := sqlc.AdminSummarySessionsParams{TenantID: tenant, CreatedAfter: auditTimestamp(filter.CreatedAfter), CreatedBefore: auditTimestamp(filter.CreatedBefore), AfterID: pgtype.UUID{Valid: true}} + for { + rows, err := q.AdminSummarySessions(ctx, params) + if err != nil { + return err + } + for _, row := range rows { + session, err := sessionFromRow(row.Session) + if err != nil { + return err + } + session, err = readSessionActivity(ctx, q, session) + if err != nil { + return err + } + var creator *string + if row.CreationKeyID.Valid { + id := row.CreationKeyID.String + creator = &id + } + if err := visit(session, creator); err != nil { + return err + } + params.AfterID = row.Session.ID + } + if len(rows) < 100 { + return nil + } + } + }) + return counts, err +} + +type AdminRuntimeTarget struct{ SessionID, TenantID string } +type AdminRuntimeTargetPage struct { + Data []AdminRuntimeTarget + HasMore bool +} + +func (s *Store) ListAdminRuntimeTargets(ctx context.Context, tenantIDs []string, after string, limit int, ascending bool) (AdminRuntimeTargetPage, error) { + page := AdminRuntimeTargetPage{Data: []AdminRuntimeTarget{}} + if limit < 1 || limit > 100 { + return page, ErrInvalidInput + } + tenants := make([]pgtype.UUID, 0, len(tenantIDs)) + for _, value := range tenantIDs { + id, err := parseID(value) + if err != nil { + return page, err + } + tenants = append(tenants, id) + } + params := sqlc.AdminRuntimeTargetsParams{TenantIds: tenants, Ascending: ascending, AfterID: pgtype.UUID{Valid: true}, PageLimit: int32(limit + 1)} + if after != "" { + var err error + params.AfterID, err = parseID(after) + if err != nil { + return page, ErrNotFound + } + params.AfterTime, err = s.queries.AdminRuntimeCursor(ctx, sqlc.AdminRuntimeCursorParams{ID: params.AfterID, TenantIds: tenants}) + if errors.Is(err, pgx.ErrNoRows) { + return page, ErrNotFound + } + if err != nil { + return page, err + } + } + rows, err := s.queries.AdminRuntimeTargets(ctx, params) + if err != nil { + return page, err + } + page.HasMore = len(rows) > limit + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + page.Data = append(page.Data, AdminRuntimeTarget{SessionID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String()}) + } + return page, nil +} diff --git a/services/agents-api/internal/store/admin_validation.go b/services/core/internal/store/admin_validation.go similarity index 100% rename from services/agents-api/internal/store/admin_validation.go rename to services/core/internal/store/admin_validation.go diff --git a/services/agents-api/internal/store/admin_validation_test.go b/services/core/internal/store/admin_validation_test.go similarity index 97% rename from services/agents-api/internal/store/admin_validation_test.go rename to services/core/internal/store/admin_validation_test.go index 899b34f74..121d6c7ee 100644 --- a/services/agents-api/internal/store/admin_validation_test.go +++ b/services/core/internal/store/admin_validation_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func TestAdminNameValidationIdentityAndBoundaries(t *testing.T) { diff --git a/services/agents-api/internal/store/agent_execution_defaults_http_test.go b/services/core/internal/store/agent_execution_defaults_http_test.go similarity index 96% rename from services/agents-api/internal/store/agent_execution_defaults_http_test.go rename to services/core/internal/store/agent_execution_defaults_http_test.go index be50da945..be6a5f807 100644 --- a/services/agents-api/internal/store/agent_execution_defaults_http_test.go +++ b/services/core/internal/store/agent_execution_defaults_http_test.go @@ -8,11 +8,11 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/agent_model_execution.go b/services/core/internal/store/agent_model_execution.go similarity index 97% rename from services/agents-api/internal/store/agent_model_execution.go rename to services/core/internal/store/agent_model_execution.go index 01f86658e..371b679b9 100644 --- a/services/agents-api/internal/store/agent_model_execution.go +++ b/services/core/internal/store/agent_model_execution.go @@ -6,8 +6,8 @@ import ( "errors" "fmt" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/agent_model_execution_test.go b/services/core/internal/store/agent_model_execution_test.go similarity index 98% rename from services/agents-api/internal/store/agent_model_execution_test.go rename to services/core/internal/store/agent_model_execution_test.go index e8993b147..7fee1280b 100644 --- a/services/agents-api/internal/store/agent_model_execution_test.go +++ b/services/core/internal/store/agent_model_execution_test.go @@ -8,8 +8,8 @@ import ( "sync" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/core/internal/store/agents.go b/services/core/internal/store/agents.go new file mode 100644 index 000000000..1236deb1f --- /dev/null +++ b/services/core/internal/store/agents.go @@ -0,0 +1,111 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +// SavedAgent is reusable configuration owned by an execution tenant. It has no +// engine binding or live execution state; Session snapshots are separate objects. +type SavedAgent struct { + ID string + TenantID string + Metadata map[string]string + Configuration json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateAgentInput struct { + ModelProvider *v1.ModelProviderInput + Metadata map[string]string + Configuration json.RawMessage +} + +// CreateAgent stores caller-validated, credential-free configuration. Public +// defaults and schema validation belong to the API, not an execution adapter. +// Each call creates a new resource; this primitive supplies no retry semantics. +func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAgentInput) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + metadata, err := encodeMetadata(input.Metadata) + if err != nil { + return SavedAgent{}, err + } + if len(input.Configuration) == 0 || len(input.Configuration) > 512*1024 { + return SavedAgent{}, fmt.Errorf("%w: configuration must be an object of at most 512 KiB", ErrInvalidInput) + } + configuration, err := canonicalJSONObject(input.Configuration) + if err != nil { + return SavedAgent{}, err + } + if err := validateAgentModelExecution(configuration, input.ModelProvider); err != nil { + return SavedAgent{}, err + } + var created SavedAgent + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Metadata: metadata, Configuration: configuration, + }) + if err != nil { + return err + } + if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), row.ID, input.ModelProvider); err != nil { + return err + } + created, err = agentFromRow(row) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "agent", created.ID, "", AuditResource{Type: "agent", ID: created.ID}) + }) + if err != nil { + return SavedAgent{}, fmt.Errorf("create agent: %w", err) + } + return created, nil +} + +// GetAgent scopes every lookup to the authenticated caller's tenant. +func (s *Store) GetAgent(ctx context.Context, tenantID, agentID string) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + id, err := parseID(agentID) + if err != nil { + return SavedAgent{}, err + } + row, err := s.queries.GetAgent(ctx, sqlc.GetAgentParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return SavedAgent{}, ErrNotFound + } + if err != nil { + return SavedAgent{}, fmt.Errorf("get agent: %w", err) + } + return agentFromRow(row) +} + +func agentFromRow(row sqlc.Agent) (SavedAgent, error) { + agent := SavedAgent{ + ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), + Configuration: row.Configuration, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + } + if err := json.Unmarshal(row.Metadata, &agent.Metadata); err != nil { + return SavedAgent{}, fmt.Errorf("decode agent metadata: %w", err) + } + return agent, nil +} diff --git a/services/core/internal/store/agents_delete.go b/services/core/internal/store/agents_delete.go new file mode 100644 index 000000000..5c842764c --- /dev/null +++ b/services/core/internal/store/agents_delete.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// DeleteAgent removes a saved resource independently of execution snapshots. +func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", err + } + id, err := parseID(agentID) + if err != nil { + return "", err + } + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + deleted, err := q.DeleteAgent(ctx, sqlc.DeleteAgentParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(deleted.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "agent", deletedID, "") + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("delete agent: %w", err) + } + return deletedID, nil +} diff --git a/services/agents-api/internal/store/agents_delete_public_test.go b/services/core/internal/store/agents_delete_public_test.go similarity index 88% rename from services/agents-api/internal/store/agents_delete_public_test.go rename to services/core/internal/store/agents_delete_public_test.go index 4ca119d5b..4b5d9b622 100644 --- a/services/agents-api/internal/store/agents_delete_public_test.go +++ b/services/core/internal/store/agents_delete_public_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/agents_list.go b/services/core/internal/store/agents_list.go new file mode 100644 index 000000000..70c443a77 --- /dev/null +++ b/services/core/internal/store/agents_list.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type AgentPage struct { + Agents []SavedAgent + NextCursor string +} + +func (s *Store) ListAgents(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (AgentPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return AgentPage{}, err + } + if limit < 1 || limit > 100 { + return AgentPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) + } + params := sqlc.ListAgentsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetAgent(ctx, tenantID, lookupCursor(cursor)) + if err != nil { + return AgentPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListAgents(ctx, params) + if err != nil { + return AgentPage{}, fmt.Errorf("list agents: %w", err) + } + page := AgentPage{Agents: make([]SavedAgent, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + agent, err := agentFromRow(row) + if err != nil { + return AgentPage{}, err + } + page.Agents = append(page.Agents, agent) + } + return page, nil +} diff --git a/services/agents-api/internal/store/agents_list_test.go b/services/core/internal/store/agents_list_test.go similarity index 100% rename from services/agents-api/internal/store/agents_list_test.go rename to services/core/internal/store/agents_list_test.go diff --git a/services/agents-api/internal/store/agents_test.go b/services/core/internal/store/agents_test.go similarity index 100% rename from services/agents-api/internal/store/agents_test.go rename to services/core/internal/store/agents_test.go diff --git a/services/core/internal/store/agents_update.go b/services/core/internal/store/agents_update.go new file mode 100644 index 000000000..9ee2882e2 --- /dev/null +++ b/services/core/internal/store/agents_update.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// UpdateAgentInput contains validated field replacements, not a full snapshot. +// Core extension subfields merge independently. A nil metadata pointer preserves +// the existing map; a supplied map replaces it. ModelProviderSet distinguishes +// omission from replacement or an explicit nil provider, which clears the secret. +type UpdateAgentInput struct { + ModelProvider *v1.ModelProviderInput + ModelProviderSet bool + Configuration json.RawMessage + Metadata *map[string]string +} + +func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input UpdateAgentInput) (SavedAgent, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, err + } + id, err := parseID(agentID) + if err != nil { + return SavedAgent{}, err + } + raw := input.Configuration + if len(raw) == 0 { + raw = json.RawMessage(`{}`) + } + if len(raw) > 512*1024 { + return SavedAgent{}, ErrInvalidInput + } + raw, err = canonicalJSONObject(raw) + if err != nil { + return SavedAgent{}, err + } + var patch map[string]json.RawMessage + if err := json.Unmarshal(raw, &patch); err != nil { + return SavedAgent{}, err + } + var metadata []byte + if input.Metadata != nil { + metadata, err = encodeMetadata(*input.Metadata) + if err != nil { + return SavedAgent{}, err + } + } + var updated SavedAgent + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.LockAgent(ctx, sqlc.LockAgentParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + var configuration map[string]json.RawMessage + if err := json.Unmarshal(row.Configuration, &configuration); err != nil { + return err + } + if err := mergeAgentConfiguration(configuration, patch); err != nil { + return err + } + merged, err := json.Marshal(configuration) + if err != nil { + return err + } + merged, err = canonicalJSONObject(merged) + if err != nil { + return err + } + if len(merged) > 512*1024 { + return ErrInvalidInput + } + if err := validateAgentModelExecution(merged, input.ModelProvider); err != nil { + return err + } + if input.ModelProviderSet { + if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), id, input.ModelProvider); err != nil { + return err + } + } + if input.Metadata == nil { + metadata = row.Metadata + } + row, err = q.UpdateAgent(ctx, sqlc.UpdateAgentParams{TenantID: tenant, ID: id, Configuration: merged, Metadata: metadata}) + if err != nil { + return err + } + updated, err = agentFromRow(row) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "agent", updated.ID, "") + }) + if err != nil { + return SavedAgent{}, fmt.Errorf("update agent: %w", err) + } + return updated, nil +} diff --git a/services/agents-api/internal/store/agents_update_public_test.go b/services/core/internal/store/agents_update_public_test.go similarity index 88% rename from services/agents-api/internal/store/agents_update_public_test.go rename to services/core/internal/store/agents_update_public_test.go index 745070d1b..0a55d2aec 100644 --- a/services/agents-api/internal/store/agents_update_public_test.go +++ b/services/core/internal/store/agents_update_public_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/agents_update_test.go b/services/core/internal/store/agents_update_test.go similarity index 100% rename from services/agents-api/internal/store/agents_update_test.go rename to services/core/internal/store/agents_update_test.go diff --git a/services/agents-api/internal/store/archive_cancellation_test.go b/services/core/internal/store/archive_cancellation_test.go similarity index 96% rename from services/agents-api/internal/store/archive_cancellation_test.go rename to services/core/internal/store/archive_cancellation_test.go index 928c17684..5cfb2c89e 100644 --- a/services/agents-api/internal/store/archive_cancellation_test.go +++ b/services/core/internal/store/archive_cancellation_test.go @@ -11,14 +11,14 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/store/archived_cancellation_migration_test.go b/services/core/internal/store/archived_cancellation_migration_test.go similarity index 100% rename from services/agents-api/internal/store/archived_cancellation_migration_test.go rename to services/core/internal/store/archived_cancellation_migration_test.go diff --git a/services/agents-api/internal/store/artifact_capture.go b/services/core/internal/store/artifact_capture.go similarity index 98% rename from services/agents-api/internal/store/artifact_capture.go rename to services/core/internal/store/artifact_capture.go index ab8c9a152..d0584a088 100644 --- a/services/agents-api/internal/store/artifact_capture.go +++ b/services/core/internal/store/artifact_capture.go @@ -10,7 +10,7 @@ import ( "io/fs" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/artifact_lifecycle.go b/services/core/internal/store/artifact_lifecycle.go similarity index 96% rename from services/agents-api/internal/store/artifact_lifecycle.go rename to services/core/internal/store/artifact_lifecycle.go index 2088c4083..3b8492bbd 100644 --- a/services/agents-api/internal/store/artifact_lifecycle.go +++ b/services/core/internal/store/artifact_lifecycle.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/core/internal/store/auth_fixture_test.go b/services/core/internal/store/auth_fixture_test.go new file mode 100644 index 000000000..977cf4296 --- /dev/null +++ b/services/core/internal/store/auth_fixture_test.go @@ -0,0 +1,40 @@ +package store_test + +import ( + "context" + "encoding/hex" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type testAPIKey struct{ Name, TokenSHA256, TenantID, OrganizationID, ProjectID, SubjectKind, SubjectID string } +type fixtureKeyResolver map[string]store.ProjectAPIKeyBinding + +func (f fixtureKeyResolver) ResolveProjectAPIKey(_ context.Context, digest string) (store.ProjectAPIKeyBinding, error) { + if b, ok := f[digest]; ok { + return b, nil + } + return store.ProjectAPIKeyBinding{}, store.ErrNotFound +} +func newTestAuthenticator(keys []testAPIKey) (*api.Authenticator, error) { + resolver := fixtureKeyResolver{} + for _, k := range keys { + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: k.TenantID, OrganizationID: k.OrganizationID, ProjectID: k.ProjectID}, SubjectKind: k.SubjectKind, SubjectID: k.SubjectID} + if err := p.Validate(); err != nil { + return nil, err + } + digest, err := hex.DecodeString(k.TokenSHA256) + if err != nil || len(digest) != 32 { + return nil, errors.New("invalid fixture digest") + } + if _, exists := resolver[k.TokenSHA256]; exists { + return nil, errors.New("duplicate fixture digest") + } + resolver[k.TokenSHA256] = store.ProjectAPIKeyBinding{Key: store.ProjectAPIKey{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(k.TokenSHA256)).String(), Name: k.Name, Prefix: "pc_" + k.TokenSHA256[:8]}, Principal: p} + } + return api.NewDatabaseAuthenticator(resolver) +} diff --git a/services/agents-api/internal/store/claude_execution_test.go b/services/core/internal/store/claude_execution_test.go similarity index 96% rename from services/agents-api/internal/store/claude_execution_test.go rename to services/core/internal/store/claude_execution_test.go index 4b88d3316..0ab01f37b 100644 --- a/services/agents-api/internal/store/claude_execution_test.go +++ b/services/core/internal/store/claude_execution_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func claudeSession(t *testing.T, h *dispatchHarness, configuration string, prebound bool) { diff --git a/services/core/internal/store/claude_mcp_test.go b/services/core/internal/store/claude_mcp_test.go new file mode 100644 index 000000000..86b164cee --- /dev/null +++ b/services/core/internal/store/claude_mcp_test.go @@ -0,0 +1,134 @@ +package store_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { + for _, requirement := range []string{"anonymous", "bearer", "required"} { + authenticated, required := requirement == "bearer", requirement == "required" + for _, prebound := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/bound=%t", requirement, prebound), func(t *testing.T) { + h := newFunctionHarness(t) + configuration, token := mcpWorkerConfiguration, "" + if authenticated { + configuration, token = mcpBearerWorkerConfiguration(t, h) + } + if required { + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + } + claudeSession(t, h, configuration, prebound) + // Base MCP support does not imply authentication or required initialization. + caps := prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilityFromBool(authenticated || required), Preparation: proto.CapabilitySupported}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Claude MCP heartbeat", func() bool { + peer, _ := h.registry.LookupDevice(h.device.ID) + info, found, known := peer.AgentKindStatus("claude_sdk") + return known && found && info.Capabilities.MCPHTTPTools == (authenticated || required) && !info.Capabilities.MCPHTTPBearerAuth && !info.Capabilities.MCPHTTPRequired + }) + input := h.message("start", "Use declared tools only") + if prebound { + if result := <-h.run(t.Context(), input.TurnID); result.err == nil { + t.Fatal("final preclaim accepted a runtime without MCP support") + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + time.Sleep(650 * time.Millisecond) + turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal("incapable runtime claimed work", turn, err) + } + if !prebound { + if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("bound an incapable runtime", err) + } + } + caps.MCPHTTPTools, caps.MCPHTTPBearerAuth = proto.CapabilitySupported, proto.CapabilityFromBool(authenticated) + caps.MCPHTTPRequired = proto.CapabilityFromBool(required) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + var prompt proto.PromptRequestPayload + if h.read(testExecutionRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { + t.Fatal("missing typed MCP dispatch") + } + server := (*prompt.MCPHTTPServers)[0] + if server.ServerLabel != "tickets" || server.AllowedTools == nil || len(*server.AllowedTools) != 0 || server.Required != required || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents { + t.Fatal("MCP configuration changed during dispatch") + } + endpoint := "http://127.0.0.1:9191/mcp" + if authenticated { + endpoint = "https://mcp.example/tools" + } + if server.ServerURL != endpoint || (token != "" && (server.BearerToken == nil || *server.BearerToken != token)) || (token == "" && server.BearerToken != nil) { + t.Fatal("dispatch lost scoped authentication or authenticated an anonymous server") + } + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + waitTurn(t, h, input.TurnID, store.TurnCompleted) + }) + } + } +} + +func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { + for _, profile := range []string{"missing required capability", "reserved label"} { + t.Run(profile, func(t *testing.T) { + h := newDispatchHarness(t) + configuration := mcpWorkerConfiguration + switch profile { + case "missing required capability": + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + case "reserved label": + configuration = strings.Replace(configuration, `"tickets"`, `"functions"`, 1) + } + claudeSession(t, h, configuration, true) + caps := prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableTurns: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported, ExecutionControls: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, ToolObservations: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPRequired: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, Preparation: proto.CapabilitySupported}) + if profile == "missing required capability" { + caps.MCPHTTPRequired = proto.CapabilityUnsupported + } + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) + deadline := time.Now().Add(3 * time.Second) + for { + peer, _ := h.registry.LookupDevice(h.device.ID) + if info, _, _ := peer.AgentKindStatus("claude_sdk"); info.Capabilities.MCPHTTPBearerAuth { + break + } + if time.Now().After(deadline) { + t.Fatal("heartbeat missing") + } + time.Sleep(10 * time.Millisecond) + } + input := h.message("start", "Run") + if result := <-h.run(t.Context(), input.TurnID); result.err == nil { + t.Fatal("unsupported MCP configuration claimed") + } + turn, err := h.s.GetTurn(t.Context(), h.tenant, h.session.ID, input.TurnID) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal(turn, err) + } + }) + } +} diff --git a/services/core/internal/store/command_output_test.go b/services/core/internal/store/command_output_test.go new file mode 100644 index 000000000..af7c6b9d2 --- /dev/null +++ b/services/core/internal/store/command_output_test.go @@ -0,0 +1,151 @@ +package store_test + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { + ctx := context.Background() + s, pool := store.NewTestStore(t) + defer pool.Close() + tenant := uuid.NewString() + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "command-output"}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(ctx, tenant, session.ID, "start", json.RawMessage(`{"text":"run commands"}`)) + if err != nil { + t.Fatal(err) + } + if _, err = s.TransitionTurn(ctx, tenant, session.ID, input.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + event := func(kind, raw string) store.ExecutionEvent { + return store.ExecutionEvent{Kind: kind, Payload: json.RawMessage(raw)} + } + batch := []store.ExecutionEvent{ + event("tool_call", `{"id":"cmd","stage":"before","observation":{"kind":"command","command":"run","status":"in_progress"}}`), + event("command_output", `{"id":"cmd","delta":"same\n"}`), + event("command_output", `{"id":"cmd","delta":"same\n"}`), + } + for range 2 { + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + t.Fatal(err) + } + } + before, _ := s.SessionEventCursor(ctx, tenant, session.ID) + // A bad command reference rolls back preceding valid fragments and their events. + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, []store.ExecutionEvent{ + event("command_output", `{"id":"cmd","delta":"rollback"}`), + event("command_output", `{"id":"unknown","delta":"orphan"}`), + }); err == nil { + t.Fatal("unknown command accepted") + } + after, _ := s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("rollback published output") + } + page, err := store.New(pool).ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 2 { + t.Fatalf("read draft: %+v %v", page, err) + } + if page.Items[1].Output != "same\nsame\n" { + t.Fatal("draft output lost", page.Items[1]) + } + final := []store.ExecutionEvent{ + event("tool_call", `{"id":"cmd","stage":"after","observation":{"kind":"command","command":"run","status":"completed","output":"authoritative","exit_code":0}}`), + event("command_output", `{"id":"cmd","delta":"late"}`), + event("tool_call", `{"id":"partial","stage":"before","observation":{"kind":"command","command":"wait","status":"in_progress"}}`), + event("command_output", `{"id":"partial","delta":"已观察\n"}`), + } + if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { + t.Fatal(err) + } + if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, store.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { + t.Fatal(err) + } + // Reopening the Store recovers committed Items without creating events. + reopened := store.New(pool) + before, _ = s.SessionEventCursor(ctx, tenant, session.ID) + page, err = reopened.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Items) != 3 { + t.Fatalf("recovery: %+v %v", page, err) + } + if page.Items[1].Status != "completed" || page.Items[1].Output != "authoritative" || page.Items[2].Status != "incomplete" || page.Items[2].Output != "已观察\n" { + t.Fatal("completion/cancellation lost command output", page.Items) + } + after, _ = s.SessionEventCursor(ctx, tenant, session.ID) + if before != after { + t.Fatal("query replayed events") + } + if _, err := reopened.ListSessionEvents(ctx, uuid.NewString(), session.ID, 0); !errors.Is(err, store.ErrNotFound) { + t.Fatal("foreign event access", err) + } + var fragments []string + added, done := map[string]bool{}, map[string]bool{} + indexes := map[string]int32{} + cursor := int64(0) + for { + changes, err := reopened.ListSessionEvents(ctx, tenant, session.ID, cursor) + if err != nil { + t.Fatal(err) + } + if len(changes) == 0 { + break + } + for _, change := range changes { + e := change.Event + if e.Item != nil && e.Item.Type == "command_execution" { + if e.Type == "agent.session.turn.item.added" { + added[e.Item.ID] = true + indexes[e.Item.ID] = *e.OutputIndex + } + if e.Type == "agent.session.turn.item.done" { + done[e.Item.ID] = true + } + } + if e.Type == "agent.output.command_execution_output.delta" { + if !added[e.ItemID] || done[e.ItemID] || e.OutputIndex == nil || *e.OutputIndex != indexes[e.ItemID] || e.TurnID != input.TurnID || e.SessionID != session.ID || e.EventID == "" { + t.Fatal("invalid command delta identity/order", e) + } + fragments = append(fragments, *e.Delta) + } + cursor = change.Sequence + } + } + if !reflect.DeepEqual(fragments, []string{"same\n", "same\n", "已观察\n"}) || len(done) != 2 { + t.Fatal("incorrect live fragments", fragments, done) + } +} + +func TestExecutionJournalsCommandOutputBeforeCancellation(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("command", "run a command") + result := h.run(ctx, input.TurnID) + h.read(testExecutionRequest) + h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "cmd", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "wait", Status: "in_progress"}}) + h.write(input.TurnID, proto.TypeCommandOutput, proto.CommandOutputPayload{ID: "cmd", Delta: "partial"}) + if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + env := h.read(proto.TypePromptCancel) + var cancel proto.PromptCancelPayload + if err := env.DecodePayload(&cancel); err != nil { + t.Fatal(err) + } + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + h.finished(result, store.TurnCancelled) + page, err := h.s.ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || page.Items[1].Output != "partial" { + t.Fatalf("journal/cancellation lost partial output: %+v %v", page, err) + } +} diff --git a/services/agents-api/internal/store/configuration_validation_public_test.go b/services/core/internal/store/configuration_validation_public_test.go similarity index 98% rename from services/agents-api/internal/store/configuration_validation_public_test.go rename to services/core/internal/store/configuration_validation_public_test.go index fb56fb1a5..110d6080e 100644 --- a/services/agents-api/internal/store/configuration_validation_public_test.go +++ b/services/core/internal/store/configuration_validation_public_test.go @@ -8,10 +8,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/core_metrics.go b/services/core/internal/store/core_metrics.go new file mode 100644 index 000000000..6e2de85ce --- /dev/null +++ b/services/core/internal/store/core_metrics.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type CoreExecutionSnapshot struct { + QueuedTurns, WaitingForDaemon, InProgressTurns int64 + OldestQueuedSeconds *float64 +} + +type CoreQueueWait struct{ P50, P95 *float64 } + +type CoreQueueWaitBucket struct { + Start time.Time + P95MS *float64 +} + +type CoreExecutionHistory struct { + Interrupted int64 + QueueWaitMS CoreQueueWait + Buckets []CoreQueueWaitBucket +} + +// ReadCoreExecutionSnapshot counts persisted root Turns across the deployment. +// WaitingForDaemon is the queued subset whose Session binding is absent from the +// supplied live registry IDs; callers must distinguish an unavailable registry +// from an observed empty one before using this method. Age is in seconds, and is +// nil when the queue is empty. No Session deletion filter hides operational state. +func (s *Store) ReadCoreExecutionSnapshot(ctx context.Context, now time.Time, connectedDeviceIDs []string) (CoreExecutionSnapshot, error) { + ids := make([]pgtype.UUID, 0, len(connectedDeviceIDs)) + for _, value := range connectedDeviceIDs { + id, err := parseID(value) + if err != nil { + return CoreExecutionSnapshot{}, err + } + ids = append(ids, id) + } + row, err := s.queries.CoreExecutionSnapshot(ctx, sqlc.CoreExecutionSnapshotParams{ + ConnectedDeviceIds: ids, ObservedAt: pgtype.Timestamptz{Time: now, Valid: true}, + }) + if err != nil { + return CoreExecutionSnapshot{}, err + } + result := CoreExecutionSnapshot{QueuedTurns: row.QueuedTurns, WaitingForDaemon: row.WaitingForDaemon, InProgressTurns: row.InProgressTurns} + if row.QueuedTurns > 0 { + result.OldestQueuedSeconds = &row.OldestQueuedSeconds + } + return result, nil +} + +// ReadCoreExecutionHistory reads a bounded, repeatable read-only snapshot of root +// Turn history. The interval is [start,end), with complete epoch-aligned buckets. +// Interruptions use failed Turns' completed_at and execution_interrupted error +// code. Queue waits use started_at-created_at in milliseconds, grouped by +// started_at, including retained history of deleted Sessions. Counts of an empty +// interval are zero; percentile values without observations are nil. Read errors +// invalidate the entire result and must not be presented as measured zeros. +func (s *Store) ReadCoreExecutionHistory(ctx context.Context, start, end time.Time, resolution time.Duration) (CoreExecutionHistory, error) { + span := end.Sub(start) + if resolution < time.Second || resolution%time.Second != 0 || span <= 0 || span > 7*24*time.Hour || + span%resolution != 0 || span/resolution > 1008 || start.Nanosecond() != 0 || end.Nanosecond() != 0 || + start.Unix()%int64(resolution/time.Second) != 0 || end.Unix()%int64(resolution/time.Second) != 0 { + return CoreExecutionHistory{}, ErrInvalidInput + } + result := CoreExecutionHistory{Buckets: make([]CoreQueueWaitBucket, int(span/resolution))} + for i := range result.Buckets { + result.Buckets[i].Start = start.Add(time.Duration(i) * resolution).UTC() + } + first, last := pgtype.Timestamptz{Time: start, Valid: true}, pgtype.Timestamptz{Time: end, Valid: true} + err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + result.Interrupted, err = q.CoreInterruptedTurns(ctx, sqlc.CoreInterruptedTurnsParams{RangeStart: first, RangeEnd: last}) + if err != nil { + return err + } + wait, err := q.CoreQueueWaitSummary(ctx, sqlc.CoreQueueWaitSummaryParams{RangeStart: first, RangeEnd: last}) + if err != nil { + return err + } + if wait.Samples > 0 { + result.QueueWaitMS = CoreQueueWait{P50: &wait.P50Ms, P95: &wait.P95Ms} + } + rows, err := q.CoreQueueWaitBuckets(ctx, sqlc.CoreQueueWaitBucketsParams{RangeStart: first, RangeEnd: last, ResolutionSeconds: int32(resolution / time.Second)}) + if err != nil { + return err + } + for _, row := range rows { + if row.Samples > 0 { + result.Buckets[row.BucketNumber].P95MS = &row.P95Ms + } + } + return nil + }) + if err != nil { + return CoreExecutionHistory{}, err + } + return result, nil +} + +// ReadCoreDatabaseSize measures the current PostgreSQL database in bytes. +func (s *Store) ReadCoreDatabaseSize(ctx context.Context) (int64, error) { + return s.queries.CoreDatabaseSize(ctx) +} diff --git a/services/agents-api/internal/store/core_metrics_test.go b/services/core/internal/store/core_metrics_test.go similarity index 100% rename from services/agents-api/internal/store/core_metrics_test.go rename to services/core/internal/store/core_metrics_test.go diff --git a/services/agents-api/internal/store/creation_stream_settlement_public_test.go b/services/core/internal/store/creation_stream_settlement_public_test.go similarity index 97% rename from services/agents-api/internal/store/creation_stream_settlement_public_test.go rename to services/core/internal/store/creation_stream_settlement_public_test.go index 8f6ab0ca3..37e2e5f91 100644 --- a/services/agents-api/internal/store/creation_stream_settlement_public_test.go +++ b/services/core/internal/store/creation_stream_settlement_public_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/credential_matrix_http_test.go b/services/core/internal/store/credential_matrix_http_test.go similarity index 93% rename from services/agents-api/internal/store/credential_matrix_http_test.go rename to services/core/internal/store/credential_matrix_http_test.go index 3589053a5..6f7c88824 100644 --- a/services/agents-api/internal/store/credential_matrix_http_test.go +++ b/services/core/internal/store/credential_matrix_http_test.go @@ -10,13 +10,13 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/credential_oauth_migration_test.go b/services/core/internal/store/credential_oauth_migration_test.go similarity index 100% rename from services/agents-api/internal/store/credential_oauth_migration_test.go rename to services/core/internal/store/credential_oauth_migration_test.go diff --git a/services/agents-api/internal/store/credential_oauth_secret.go b/services/core/internal/store/credential_oauth_secret.go similarity index 97% rename from services/agents-api/internal/store/credential_oauth_secret.go rename to services/core/internal/store/credential_oauth_secret.go index 667c9fa62..aaa1f0c3c 100644 --- a/services/agents-api/internal/store/credential_oauth_secret.go +++ b/services/core/internal/store/credential_oauth_secret.go @@ -6,7 +6,7 @@ import ( "reflect" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" ) // The encrypted copy authenticates every public setting used for refresh, including diff --git a/services/agents-api/internal/store/credential_oauth_types.go b/services/core/internal/store/credential_oauth_types.go similarity index 100% rename from services/agents-api/internal/store/credential_oauth_types.go rename to services/core/internal/store/credential_oauth_types.go diff --git a/services/agents-api/internal/store/credential_status_migration_test.go b/services/core/internal/store/credential_status_migration_test.go similarity index 100% rename from services/agents-api/internal/store/credential_status_migration_test.go rename to services/core/internal/store/credential_status_migration_test.go diff --git a/services/agents-api/internal/store/deployment_model_providers.go b/services/core/internal/store/deployment_model_providers.go similarity index 97% rename from services/agents-api/internal/store/deployment_model_providers.go rename to services/core/internal/store/deployment_model_providers.go index 3eff33491..886f6a635 100644 --- a/services/agents-api/internal/store/deployment_model_providers.go +++ b/services/core/internal/store/deployment_model_providers.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/deployment_model_providers_http_test.go b/services/core/internal/store/deployment_model_providers_http_test.go similarity index 97% rename from services/agents-api/internal/store/deployment_model_providers_http_test.go rename to services/core/internal/store/deployment_model_providers_http_test.go index 0c0d2dc7e..bda363fce 100644 --- a/services/agents-api/internal/store/deployment_model_providers_http_test.go +++ b/services/core/internal/store/deployment_model_providers_http_test.go @@ -10,13 +10,13 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/deployment_model_providers_test.go b/services/core/internal/store/deployment_model_providers_test.go similarity index 96% rename from services/agents-api/internal/store/deployment_model_providers_test.go rename to services/core/internal/store/deployment_model_providers_test.go index 0530a3943..62542d505 100644 --- a/services/agents-api/internal/store/deployment_model_providers_test.go +++ b/services/core/internal/store/deployment_model_providers_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/deployment_provider_observation_lock_test.go b/services/core/internal/store/deployment_provider_observation_lock_test.go similarity index 96% rename from services/agents-api/internal/store/deployment_provider_observation_lock_test.go rename to services/core/internal/store/deployment_provider_observation_lock_test.go index 102f2bd04..a266138c5 100644 --- a/services/agents-api/internal/store/deployment_provider_observation_lock_test.go +++ b/services/core/internal/store/deployment_provider_observation_lock_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) func TestDeploymentObservationClockSampleFollowsRowLock(t *testing.T) { diff --git a/services/core/internal/store/deployment_provider_observations.go b/services/core/internal/store/deployment_provider_observations.go new file mode 100644 index 000000000..555289d62 --- /dev/null +++ b/services/core/internal/store/deployment_provider_observations.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "strconv" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +// ObserveDeploymentModelProvider attempts one metadata UPDATE through the pool, +// never the leased execution connection. SQL verifies the committed root outcome +// and exact frozen revision without loading credentials. The metadata transaction +// installs server-side timeouts: client cancellation alone can leave PostgreSQL +// executing briefly after pgx has returned a deadline error. +func (s *Store) ObserveDeploymentModelProvider(ctx context.Context, tenantID, sessionID, turnID string) (int64, error) { + lookup, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return 0, err + } + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) + deadline, _ := ctx.Deadline() + // Leave a small part of the overall budget for returning the server error + // and releasing this metadata-only transaction before the client deadline. + timeout := time.Until(deadline).Milliseconds() - 25 + if timeout <= 0 { + return 0, context.DeadlineExceeded + } + setting := strconv.FormatInt(timeout, 10) + "ms" + if _, err = tx.Exec(ctx, "SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $1, true)", setting); err != nil { + return 0, err + } + count, err := sqlc.New(tx).ObserveDeploymentModelProvider(ctx, sqlc.ObserveDeploymentModelProviderParams{ + TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID, + }) + if err != nil { + return 0, err + } + if err = tx.Commit(ctx); err != nil { + return 0, err + } + return count, nil +} diff --git a/services/core/internal/store/deployment_provider_observations_test.go b/services/core/internal/store/deployment_provider_observations_test.go new file mode 100644 index 000000000..4fb9ea38e --- /dev/null +++ b/services/core/internal/store/deployment_provider_observations_test.go @@ -0,0 +1,424 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type providerObservationFixture struct { + s *Store + pool *pgxpool.Pool + tenant string + input CreateSessionInput + session Session +} + +func observationAdmin(t *testing.T) context.Context { + return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) +} +func newProviderObservationFixture(t *testing.T) providerObservationFixture { + t.Helper() + s, pool := newManagedTestStore(t) + provider := FixtureModelProvider("codex") + if _, err := s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *provider, Model: "fixture"}); err != nil { + t.Fatal(err) + } + snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") + if err != nil { + t.Fatal(err) + } + input := executionProjectionInput("deployment") + input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"none"}}`) + input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision = snapshot.Provider, "deployment", snapshot.Revision + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "deployment"} + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + return providerObservationFixture{s, pool, tenant, input, session} +} +func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nativeCode string) Turn { + t.Helper() + receipt := submitMessage(t, f.s, f.tenant, f.session.ID, uuid.NewString()) + transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + outcome, _ := json.Marshal(map[string]string{"error_code": coreCode, "engine_error_code": nativeCode}) + turn, err := f.s.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, status, outcome, "", receipt.Sequence) + if err != nil { + t.Fatal(err) + } + return turn +} +func (f providerObservationFixture) observe(t *testing.T, turn Turn, want int64) { + t.Helper() + n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) + if err != nil || n != want { + t.Fatalf("observation writes=%d want=%d err=%v", n, want, err) + } +} +func (f providerObservationFixture) revision(t *testing.T) uuid.UUID { + t.Helper() + var rev uuid.UUID + if err := f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&rev); err != nil { + t.Fatal(err) + } + return rev +} +func TestDeploymentObservationSnapshotReplacementAndRetry(t *testing.T) { + f := newProviderObservationFixture(t) + original := f.revision(t) + // Resolution and insertion have independent boundaries: retain the tuple while + // a PUT replaces the default, then create with that exact earlier tuple. + before, err := f.s.DeploymentModelProvider(t.Context(), "codex") + if err != nil { + t.Fatal(err) + } + replacement := *before.Provider + replacement.APIKey = "different-fixture-key" + if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}); err != nil { + t.Fatal(err) + } + if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET updated_at='2000-01-01'"); err != nil { + t.Fatal(err) + } + input := f.input + input.IdempotencyKey = uuid.NewString() + stale, err := f.s.CreateSession(t.Context(), f.tenant, input) + if err != nil { + t.Fatal(err) + } + staleFixture := f + staleFixture.session = stale + if staleFixture.revision(t) != original { + t.Fatal("tuple revision changed during insertion") + } + frozen, err := f.s.SessionModelExecution(t.Context(), f.tenant, stale.ID) + if err != nil || frozen == nil || *frozen != *before.Provider { + t.Fatal("frozen tuple bundle changed", err) + } + staleFixture.observe(t, staleFixture.terminal(t, TurnFailed, "engine_failed", "authentication_error"), 0) + current, _ := f.s.DeploymentModelProvider(t.Context(), "codex") + retry := f.input + retry.ModelProvider = current.Provider + retry.DeploymentProviderRevision = current.Revision + replay, err := f.s.CreateSession(t.Context(), f.tenant, retry) + if err != nil || replay.ID != f.session.ID || f.revision(t) != original { + t.Fatal("retry replaced frozen metadata", err) + } + if err = f.s.DeleteDeploymentModelProvider(observationAdmin(t), "codex"); err != nil { + t.Fatal(err) + } + if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *before.Provider, Model: "fixture"}); err != nil { + t.Fatal(err) + } + recreated, _ := f.s.DeploymentModelProvider(t.Context(), "codex") + if recreated.Revision == original || recreated.Revision == current.Revision { + t.Fatal("revision reused") + } + frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) + if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { + t.Fatal("migration/replacement changed Session bundle", err) + } + f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) +} +func TestDeploymentObservationEligibilityAndReset(t *testing.T) { + allowed := []string{"authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"} + f := newProviderObservationFixture(t) + for _, code := range allowed { + if _, err := f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { + t.Fatal(err) + } + f.observe(t, f.terminal(t, TurnFailed, "engine_failed", code), 1) + } + for _, tc := range []struct{ status, core, code string }{{TurnFailed, "engine_failed", "context_length_exceeded"}, {TurnFailed, "engine_failed", "cyber_policy"}, {TurnFailed, "engine_failed", "harness_error"}, {TurnFailed, "engine_failed", "untrusted raw text"}, {TurnFailed, "input_not_applied", "authentication_error"}, {TurnFailed, "device_disconnected", "authentication_error"}, {TurnCancelled, "engine_failed", "authentication_error"}} { + f.observe(t, f.terminal(t, tc.status, tc.core, tc.code), 0) + } + success := f.terminal(t, TurnCompleted, "", "") + f.observe(t, success, 1) + n, err := f.s.ObserveDeploymentModelProvider(t.Context(), uuid.NewString(), f.session.ID, success.ID) + if n != 0 || err != nil { + t.Fatal("foreign tenant observed", err) + } + view, _ := f.s.ListDeploymentModelProviders(t.Context()) + if view[0].LastUsedAt == nil || view[0].LastErrorAt == nil { + t.Fatal("safe observations missing") + } + old := f.revision(t) + reset, err := f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *f.input.ModelProvider, Model: "fixture"}) + if err != nil { + t.Fatal(err) + } + snapshot, _ := f.s.DeploymentModelProvider(t.Context(), "codex") + if snapshot.Revision == old || reset.LastUsedAt != nil || reset.LastErrorAt != nil || reset.LastErrorCode != nil { + t.Fatal("identical PUT did not reset") + } + f.observe(t, success, 0) +} +func TestDeploymentObservationSourceAndHistoricalExclusion(t *testing.T) { + f := newProviderObservationFixture(t) + for _, source := range []string{"session", "agent", "unknown", "deployment"} { + input := f.input + input.IdempotencyKey = uuid.NewString() + projection := *input.ExecutionConfiguration + input.ExecutionConfiguration = &projection + input.ModelProviderSource = source + // Historical metadata may name deployment but has no frozen private UUID. + if source == "deployment" { + input.DeploymentProviderRevision = uuid.Nil + } else { + input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`) + if source == "unknown" { + input.ModelProviderSource = "session" + } + } + projection.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + session, err := f.s.CreateSession(t.Context(), f.tenant, input) + if err != nil { + t.Fatal(source, err) + } + var revision pgtype.UUID + if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&revision); err != nil || revision.Valid { + t.Fatal("non-deployment/historical revision persisted", source, err) + } + // Use a committed fixture outcome directly: hosted dispatch is a separate gate. + id := uuid.NewString() + if _, err = f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at) VALUES($1,$2,'completed','{}',clock_timestamp())", id, session.ID); err != nil { + t.Fatal(err) + } + n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, session.ID, id) + if err != nil || n != 0 { + t.Fatal("ineligible source observed", source, err) + } + } +} +func TestDeploymentObservationConcurrentThrottleAndRecovery(t *testing.T) { + f := newProviderObservationFixture(t) + successes := []Turn{} + failures := []Turn{} + for range 8 { + successes = append(successes, f.terminal(t, TurnCompleted, "", "")) + } + for i := range 8 { + code := []string{"authentication_error", "rate_limit_exceeded"}[i%2] + failures = append(failures, f.terminal(t, TurnFailed, "engine_failed", code)) + } + concurrent := func(turns []Turn, want int64) { + t.Helper() + var wg sync.WaitGroup + counts := make(chan int64, len(turns)) + errs := make(chan error, len(turns)) + for _, turn := range turns { + wg.Add(1) + go func() { + defer wg.Done() + n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) + counts <- n + errs <- err + }() + } + wg.Wait() + close(counts) + close(errs) + var n int64 + for v := range counts { + n += v + } + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if n != want { + t.Fatalf("concurrent writes=%d want=%d", n, want) + } + } + concurrent(successes, 1) + concurrent(failures, 1) + concurrent(successes, 1) + concurrent(successes, 0) + concurrent(failures, 0) +} + +// Only the DB clock sample is controlled; execute the actual generated query, +// retaining PostgreSQL locking, predicates, constraints and write count. +type observationClockDB struct { + *pgxpool.Pool + at time.Time + query string +} + +func (db *observationClockDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + db.query = sql + sql = strings.ReplaceAll(sql, "clock_timestamp()", "'"+db.at.Format(time.RFC3339Nano)+"'::timestamptz") + return db.Pool.Exec(ctx, sql, args...) +} +func TestDeploymentObservationClockBoundariesRollbackAndPlan(t *testing.T) { + f := newProviderObservationFixture(t) + success := f.terminal(t, TurnCompleted, "", "") + failure := f.terminal(t, TurnFailed, "engine_failed", "authentication_error") + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + db := &observationClockDB{Pool: f.pool} + q := sqlc.New(db) + observe := func(turn Turn, offset time.Duration, want int64) { + t.Helper() + db.at = base.Add(offset) + lookup, _ := turnLookup(f.tenant, f.session.ID, turn.ID) + n, err := q.ObserveDeploymentModelProvider(t.Context(), sqlc.ObserveDeploymentModelProviderParams{TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID}) + if err != nil || n != want { + t.Fatalf("at %s got %d want %d: %v", offset, n, want, err) + } + } + observe(success, 0, 1) + observe(failure, time.Second, 1) + observe(success, 2*time.Second, 1) + observe(failure, 30*time.Second, 0) + observe(failure, 31*time.Second, 1) + observe(success, 31*time.Second, 1) + observe(success, 31*time.Second, 0) + // A new accepted error can recover once even if the clock moves backwards. + observe(failure, 61*time.Second, 1) + observe(success, -time.Second, 1) + observe(success, -time.Second, 0) + observe(success, 29*time.Second, 1) + observe(success, 59*time.Second-time.Microsecond, 0) + observe(success, 59*time.Second, 1) + var actual time.Time + if err := f.pool.QueryRow(t.Context(), "SELECT last_used_at FROM deployment_model_providers").Scan(&actual); err != nil || !actual.Equal(base.Add(59*time.Second)) { + t.Fatal("timestamp was clamped", err) + } + lookup, _ := turnLookup(f.tenant, f.session.ID, success.ID) + // EXPLAIN uses the normal production planner and does not execute the update. + // Tenant/Session and root Turn lookups must constrain the default lookup. + tx, err := f.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + rows, err := tx.Query(t.Context(), "EXPLAIN "+db.query, lookup.TenantID, lookup.SessionID, lookup.ID) + if err != nil { + t.Fatal(err) + } + var plan strings.Builder + for rows.Next() { + var line string + _ = rows.Scan(&line) + plan.WriteString(line) + } + rows.Close() + if rows.Err() != nil || (!strings.Contains(plan.String(), "deployment_model_providers_pkey") || (!strings.Contains(plan.String(), "turns_pkey") && !strings.Contains(plan.String(), "turns_session_id_id_key"))) { + t.Fatal("bounded lookup indexes absent", rows.Err(), plan.String()) + } +} +func TestDeploymentObservationReplacementLockRecheckAndTimeout(t *testing.T) { + for _, remove := range []bool{false, true} { + t.Run(fmt.Sprint("delete=", remove), func(t *testing.T) { + f := newProviderObservationFixture(t) + turn := f.terminal(t, TurnCompleted, "", "") + tx, err := f.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond) + defer cancel() + if n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID); err == nil || n != 0 { + t.Fatal("locked observation did not time out") + } + done := make(chan int64, 1) + errs := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID) + done <- n + errs <- err + }() + // Verify it actually reached a PostgreSQL lock wait, rather than racing on + // goroutine scheduling, before replacing/deleting the selected revision. + deadline := time.Now().Add(time.Second) + waiting := false + for time.Now().Before(deadline) { + if err = f.pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%ObserveDeploymentModelProvider%')").Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting { + break + } + time.Sleep(time.Millisecond) + } + if !waiting { + t.Fatal("observation never waited on row lock") + } + if remove { + _, err = tx.Exec(t.Context(), "DELETE FROM deployment_model_providers") + } else { + _, err = tx.Exec(t.Context(), "UPDATE deployment_model_providers SET revision=$1", uuid.New()) + } + if err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if n := <-done; n != 0 { + t.Fatal("stale revision updated replacement", n) + } + if err = <-errs; err != nil { + t.Fatal(err) + } + }) + } +} +func TestDeploymentObservationMigrationRoundTrip(t *testing.T) { + f := newProviderObservationFixture(t) + old := f.revision(t) + var secret []byte + if err := f.pool.QueryRow(t.Context(), "SELECT encrypted_config FROM deployment_model_providers").Scan(&secret); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile("../../migrations/000084_deployment_provider_observations.sql") + if err != nil { + t.Fatal(err) + } + parts := strings.Split(string(raw), "-- +goose Down") + if _, err = f.pool.Exec(t.Context(), parts[1]); err != nil { + t.Fatal(err) + } + if _, err = f.pool.Exec(t.Context(), parts[0]); err != nil { + t.Fatal(err) + } + var revision uuid.UUID + var frozen pgtype.UUID + var preserved []byte + if err = f.pool.QueryRow(t.Context(), "SELECT revision,encrypted_config FROM deployment_model_providers").Scan(&revision, &preserved); err != nil { + t.Fatal(err) + } + if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&frozen); err != nil { + t.Fatal(err) + } + if revision == old || frozen.Valid || string(secret) != string(preserved) { + t.Fatal("migration recreated historical identity or changed ciphertext") + } + frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) + if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { + t.Fatal("migration/replacement changed Session bundle", err) + } + f.observe(t, f.terminal(t, TurnCompleted, "", ""), 0) +} diff --git a/services/agents-api/internal/store/device_bootstrap_binding_test.go b/services/core/internal/store/device_bootstrap_binding_test.go similarity index 96% rename from services/agents-api/internal/store/device_bootstrap_binding_test.go rename to services/core/internal/store/device_bootstrap_binding_test.go index 1cda38083..af7d7983c 100644 --- a/services/agents-api/internal/store/device_bootstrap_binding_test.go +++ b/services/core/internal/store/device_bootstrap_binding_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/devices.go b/services/core/internal/store/devices.go similarity index 97% rename from services/agents-api/internal/store/devices.go rename to services/core/internal/store/devices.go index b3ef507e9..f84b4e3a4 100644 --- a/services/agents-api/internal/store/devices.go +++ b/services/core/internal/store/devices.go @@ -11,9 +11,9 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) var ErrDeviceBindingConflict = errors.New("session is already bound to a different device") diff --git a/services/agents-api/internal/store/devices_test.go b/services/core/internal/store/devices_test.go similarity index 95% rename from services/agents-api/internal/store/devices_test.go rename to services/core/internal/store/devices_test.go index 44e077532..cd48b13f0 100644 --- a/services/agents-api/internal/store/devices_test.go +++ b/services/core/internal/store/devices_test.go @@ -15,10 +15,10 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" ) func registerTestDevice(t *testing.T, s *Store, tenant string) (ExecutionDevice, string) { diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/core/internal/store/dispatch_test.go similarity index 97% rename from services/agents-api/internal/store/dispatch_test.go rename to services/core/internal/store/dispatch_test.go index a37897bf2..cfc76942f 100644 --- a/services/agents-api/internal/store/dispatch_test.go +++ b/services/core/internal/store/dispatch_test.go @@ -11,13 +11,13 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/store/environment_admission_test.go b/services/core/internal/store/environment_admission_test.go similarity index 97% rename from services/agents-api/internal/store/environment_admission_test.go rename to services/core/internal/store/environment_admission_test.go index a3f2a37da..6e45ffe44 100644 --- a/services/agents-api/internal/store/environment_admission_test.go +++ b/services/core/internal/store/environment_admission_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_claim_worker_test.go b/services/core/internal/store/environment_claim_worker_test.go similarity index 94% rename from services/agents-api/internal/store/environment_claim_worker_test.go rename to services/core/internal/store/environment_claim_worker_test.go index 641550062..560af0cec 100644 --- a/services/agents-api/internal/store/environment_claim_worker_test.go +++ b/services/core/internal/store/environment_claim_worker_test.go @@ -6,9 +6,9 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerReconcilesEnvironmentPromotionBeforeStart(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_connection_events_test.go b/services/core/internal/store/environment_connection_events_test.go similarity index 94% rename from services/agents-api/internal/store/environment_connection_events_test.go rename to services/core/internal/store/environment_connection_events_test.go index 948446328..29a7127c1 100644 --- a/services/agents-api/internal/store/environment_connection_events_test.go +++ b/services/core/internal/store/environment_connection_events_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func awaitEnvironmentConnectionState(t *testing.T, ctx context.Context, s *store.Store, tenant, environment, status string) { diff --git a/services/agents-api/internal/store/environment_connection_migration_test.go b/services/core/internal/store/environment_connection_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environment_connection_migration_test.go rename to services/core/internal/store/environment_connection_migration_test.go diff --git a/services/agents-api/internal/store/environment_connection_recovery.go b/services/core/internal/store/environment_connection_recovery.go similarity index 95% rename from services/agents-api/internal/store/environment_connection_recovery.go rename to services/core/internal/store/environment_connection_recovery.go index f63123f80..3730d54f8 100644 --- a/services/agents-api/internal/store/environment_connection_recovery.go +++ b/services/core/internal/store/environment_connection_recovery.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/agents-api/internal/store/environment_connection_recovery_test.go b/services/core/internal/store/environment_connection_recovery_test.go similarity index 100% rename from services/agents-api/internal/store/environment_connection_recovery_test.go rename to services/core/internal/store/environment_connection_recovery_test.go diff --git a/services/agents-api/internal/store/environment_connection_worker_test.go b/services/core/internal/store/environment_connection_worker_test.go similarity index 91% rename from services/agents-api/internal/store/environment_connection_worker_test.go rename to services/core/internal/store/environment_connection_worker_test.go index 9d5edf973..0d8073f1d 100644 --- a/services/agents-api/internal/store/environment_connection_worker_test.go +++ b/services/core/internal/store/environment_connection_worker_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_connections.go b/services/core/internal/store/environment_connections.go similarity index 97% rename from services/agents-api/internal/store/environment_connections.go rename to services/core/internal/store/environment_connections.go index 54685242f..f2db611c8 100644 --- a/services/agents-api/internal/store/environment_connections.go +++ b/services/core/internal/store/environment_connections.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/environment_connections_test.go b/services/core/internal/store/environment_connections_test.go similarity index 100% rename from services/agents-api/internal/store/environment_connections_test.go rename to services/core/internal/store/environment_connections_test.go diff --git a/services/agents-api/internal/store/environment_device_test.go b/services/core/internal/store/environment_device_test.go similarity index 95% rename from services/agents-api/internal/store/environment_device_test.go rename to services/core/internal/store/environment_device_test.go index a8b31aede..bfb67ff8e 100644 --- a/services/agents-api/internal/store/environment_device_test.go +++ b/services/core/internal/store/environment_device_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerEnvironmentSelectsCapableDeviceWithoutMovingBinding(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_directory_active_test.go b/services/core/internal/store/environment_directory_active_test.go similarity index 93% rename from services/agents-api/internal/store/environment_directory_active_test.go rename to services/core/internal/store/environment_directory_active_test.go index 096968f1c..7cc1914ef 100644 --- a/services/agents-api/internal/store/environment_directory_active_test.go +++ b/services/core/internal/store/environment_directory_active_test.go @@ -3,8 +3,8 @@ package store_test import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_directory_test.go b/services/core/internal/store/environment_directory_test.go similarity index 97% rename from services/agents-api/internal/store/environment_directory_test.go rename to services/core/internal/store/environment_directory_test.go index f05cb4ff0..272032675 100644 --- a/services/agents-api/internal/store/environment_directory_test.go +++ b/services/core/internal/store/environment_directory_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_executor_command_test.go b/services/core/internal/store/environment_executor_command_test.go similarity index 95% rename from services/agents-api/internal/store/environment_executor_command_test.go rename to services/core/internal/store/environment_executor_command_test.go index 2b5437fa3..38db65917 100644 --- a/services/agents-api/internal/store/environment_executor_command_test.go +++ b/services/core/internal/store/environment_executor_command_test.go @@ -7,8 +7,8 @@ import ( "os/exec" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_executor_credentials.go b/services/core/internal/store/environment_executor_credentials.go similarity index 97% rename from services/agents-api/internal/store/environment_executor_credentials.go rename to services/core/internal/store/environment_executor_credentials.go index 24974d06e..3ef8f74c8 100644 --- a/services/agents-api/internal/store/environment_executor_credentials.go +++ b/services/core/internal/store/environment_executor_credentials.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/environment_executor_credentials_test.go b/services/core/internal/store/environment_executor_credentials_test.go similarity index 100% rename from services/agents-api/internal/store/environment_executor_credentials_test.go rename to services/core/internal/store/environment_executor_credentials_test.go diff --git a/services/core/internal/store/environment_executor_management.go b/services/core/internal/store/environment_executor_management.go new file mode 100644 index 000000000..3c198f944 --- /dev/null +++ b/services/core/internal/store/environment_executor_management.go @@ -0,0 +1,231 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// ExecutorCredential is the metadata of one Environment executor credential. +// Its secret is returned only when issued or rotated. +type ExecutorCredential struct { + KeyID string `json:"key_id" format:"uuid"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at" extensions:"x-nullable"` +} + +// The Project* methods serve Core-key executor credential management. project +// is the Project's own principal, which becomes the credential's execution +// principal; the target must be a self_hosted Environment of that Project whose +// Session is not deleted, and only credentials restricted to it are managed. + +// ExecutorConnectionState is an internal durable observation, never a wire payload. +// In particular the current credential digest must not be serialized. +type ExecutorConnectionState struct { + DeviceID string `json:"-"` + BoundKeyID *string `json:"-"` + EnrolledAt *time.Time `json:"-"` + LastSeenAt *time.Time `json:"-"` + CredentialHash string `json:"-"` + EnvironmentStatus string `json:"-"` +} + +type ExecutorCredentialState struct { + EnvironmentID string `json:"-"` + Credentials []ExecutorCredential + Connection ExecutorConnectionState +} + +func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project identity.Principal, environment string) ([]ExecutorCredential, error) { + state, err := s.ProjectExecutorCredentialState(ctx, project, environment) + return state.Credentials, err +} + +// ProjectExecutorCredentialState reads list metadata and binding facts in one +// read-only snapshot. The snapshot ends before any live peer/authority observation. +func (s *Store) ProjectExecutorCredentialState(ctx context.Context, project identity.Principal, environment string) (ExecutorCredentialState, error) { + if err := project.Validate(); err != nil { + return ExecutorCredentialState{}, ErrInvalidInput + } + tenant, err := parseID(project.TenantID) + if err != nil { + return ExecutorCredentialState{}, err + } + environmentID := parsePathID(environment) + result := ExecutorCredentialState{Credentials: []ExecutorCredential{}} + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.GetEnvironmentExecutorConnection(ctx, sqlc.GetEnvironmentExecutorConnectionParams{EnvironmentID: environmentID, TenantID: tenant}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + result.EnvironmentID = uuid.UUID(environmentID.Bytes).String() + result.Connection.EnvironmentStatus = row.EnvironmentStatus + if row.DeviceID.Valid { + result.Connection.DeviceID = uuid.UUID(row.DeviceID.Bytes).String() + } + if row.ExecutorKeyID.Valid { + key := uuid.UUID(row.ExecutorKeyID.Bytes).String() + result.Connection.BoundKeyID = &key + } + if row.EnrolledAt.Valid { + at := row.EnrolledAt.Time + result.Connection.EnrolledAt = &at + } + if row.LastSeenAt.Valid { + at := row.LastSeenAt.Time + result.Connection.LastSeenAt = &at + } + if row.CredentialHash.Valid { + result.Connection.CredentialHash = row.CredentialHash.String + } + rows, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ + TenantID: tenant, EnvironmentID: environmentID, + SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, + }) + if err != nil { + return err + } + for _, row := range rows { + credential := ExecutorCredential{KeyID: uuid.UUID(row.KeyID.Bytes).String(), CreatedAt: row.CreatedAt.Time} + if row.RevokedAt.Valid { + revoked := row.RevokedAt.Time + credential.RevokedAt = &revoked + } + result.Credentials = append(result.Credentials, credential) + } + return nil + }) + return result, err +} + +// IssueProjectExecutorCredential issues a new key or, with rotate, replaces the +// secret of an existing key restricted to the Environment. An archived Project +// gets neither (ErrProjectArchived). The administrator audit entry commits in +// the same transaction and never contains the secret. +func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { + // The target is checked first, then the archived Project, then the key. + if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { + return IssuedExecutorCredential{}, err + } + if err := activeProject(ctx, s.queries, project); err != nil { + return IssuedExecutorCredential{}, err + } + if !rotate { + return s.issueExecutorCredential(ctx, project, keyID, environment, activeProjectAudit(project, "issue", keyID)) + } + if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { + return IssuedExecutorCredential{}, err + } + return s.rotateExecutorCredential(ctx, project, keyID, activeProjectAudit(project, "rotate", keyID)) +} + +// RevokeProjectExecutorCredential is idempotent and also works in an archived +// Project; each successful request is audited. +func (s *Store) RevokeProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string) error { + if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { + return err + } + if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { + return err + } + tenant, id, err := executorCredentialIdentity(project, keyID) + if err != nil { + return err + } + record := executorCredentialAudit(project, "revoke", keyID) + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + n, err := q.RevokeExecutorCredential(ctx, sqlc.RevokeExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return record(ctx, q) + }) +} + +func executorCredentialAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { + return func(ctx context.Context, q *sqlc.Queries) error { + return recordAdminMutation(ctx, q, project.TenantID, action, "executor_credential", keyID) + } +} + +// activeProjectAudit repeats the archive check in the writing transaction. It +// share-locks the Project, which archiving updates, so an issuance or rotation +// either commits before the archive or sees it and fails. +func activeProjectAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { + audit := executorCredentialAudit(project, action, keyID) + return func(ctx context.Context, q *sqlc.Queries) error { + if err := activeProject(ctx, q, project); err != nil { + return err + } + return audit(ctx, q) + } +} + +// activeProject returns ErrProjectArchived for an archived Project. +func activeProject(ctx context.Context, q *sqlc.Queries, project identity.Principal) error { + tenant, err := parseID(project.TenantID) + if err != nil { + return err + } + row, err := q.LockProjectByTenant(ctx, tenant) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if row.ArchivedAt.Valid { + return ErrProjectArchived + } + return nil +} + +func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { + if err := principal.Validate(); err != nil { + return ErrInvalidInput + } + owned, err := s.GetEnvironment(ctx, principal.TenantID, environment) + if err != nil { + return err + } + var configuration struct { + Type string `json:"type"` + } + if json.Unmarshal(owned.Configuration, &configuration) != nil || configuration.Type != "self_hosted" { + return ErrNotFound + } + return nil +} + +func (s *Store) exactExecutorRestriction(ctx context.Context, principal identity.Principal, environment, keyID string) error { + tenant, id, err := executorCredentialIdentity(principal, keyID) + if err != nil { + return err + } + want := parsePathID(environment) + actual, err := s.executorCredentialRestriction(ctx, principal, tenant, id) + if err != nil { + return err + } + // Restrictions and principals are immutable, so checking before the + // rotation or revocation transaction cannot authorize a different target. + if !actual.Valid || actual != want { + return ErrNotFound + } + return nil +} diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/core/internal/store/environment_executor_management_test.go similarity index 100% rename from services/agents-api/internal/store/environment_executor_management_test.go rename to services/core/internal/store/environment_executor_management_test.go diff --git a/services/agents-api/internal/store/environment_expiry_dispatch_test.go b/services/core/internal/store/environment_expiry_dispatch_test.go similarity index 94% rename from services/agents-api/internal/store/environment_expiry_dispatch_test.go rename to services/core/internal/store/environment_expiry_dispatch_test.go index 49443f262..efb644457 100644 --- a/services/agents-api/internal/store/environment_expiry_dispatch_test.go +++ b/services/core/internal/store/environment_expiry_dispatch_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func enableEnvironmentExpiryDispatch(h *dispatchHarness) { diff --git a/services/agents-api/internal/store/environment_expiry_worker_test.go b/services/core/internal/store/environment_expiry_worker_test.go similarity index 95% rename from services/agents-api/internal/store/environment_expiry_worker_test.go rename to services/core/internal/store/environment_expiry_worker_test.go index 073ba1d38..665370260 100644 --- a/services/agents-api/internal/store/environment_expiry_worker_test.go +++ b/services/core/internal/store/environment_expiry_worker_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/environment_failure_migration_test.go b/services/core/internal/store/environment_failure_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environment_failure_migration_test.go rename to services/core/internal/store/environment_failure_migration_test.go diff --git a/services/agents-api/internal/store/environment_file_write_migration_test.go b/services/core/internal/store/environment_file_write_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environment_file_write_migration_test.go rename to services/core/internal/store/environment_file_write_migration_test.go diff --git a/services/agents-api/internal/store/environment_file_write_semantics_public_test.go b/services/core/internal/store/environment_file_write_semantics_public_test.go similarity index 96% rename from services/agents-api/internal/store/environment_file_write_semantics_public_test.go rename to services/core/internal/store/environment_file_write_semantics_public_test.go index 29b4c35d0..a72db893d 100644 --- a/services/agents-api/internal/store/environment_file_write_semantics_public_test.go +++ b/services/core/internal/store/environment_file_write_semantics_public_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_file_writes.go b/services/core/internal/store/environment_file_writes.go similarity index 98% rename from services/agents-api/internal/store/environment_file_writes.go rename to services/core/internal/store/environment_file_writes.go index 5ef8c7d71..715e1b1c6 100644 --- a/services/agents-api/internal/store/environment_file_writes.go +++ b/services/core/internal/store/environment_file_writes.go @@ -11,8 +11,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) // FileWriteIdentity binds a private mutation to one dedicated local Runtime. RequestSHA256 covers the canonical destination, byte count and data digest. diff --git a/services/agents-api/internal/store/environment_file_writes_test.go b/services/core/internal/store/environment_file_writes_test.go similarity index 99% rename from services/agents-api/internal/store/environment_file_writes_test.go rename to services/core/internal/store/environment_file_writes_test.go index 37314a11b..10f7749d9 100644 --- a/services/agents-api/internal/store/environment_file_writes_test.go +++ b/services/core/internal/store/environment_file_writes_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_initial_input_test.go b/services/core/internal/store/environment_initial_input_test.go similarity index 100% rename from services/agents-api/internal/store/environment_initial_input_test.go rename to services/core/internal/store/environment_initial_input_test.go diff --git a/services/agents-api/internal/store/environment_initial_migration_test.go b/services/core/internal/store/environment_initial_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environment_initial_migration_test.go rename to services/core/internal/store/environment_initial_migration_test.go diff --git a/services/agents-api/internal/store/environment_initial_public_test.go b/services/core/internal/store/environment_initial_public_test.go similarity index 95% rename from services/agents-api/internal/store/environment_initial_public_test.go rename to services/core/internal/store/environment_initial_public_test.go index 34fb377d5..61391d60b 100644 --- a/services/agents-api/internal/store/environment_initial_public_test.go +++ b/services/core/internal/store/environment_initial_public_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_initialization.go b/services/core/internal/store/environment_initialization.go similarity index 98% rename from services/agents-api/internal/store/environment_initialization.go rename to services/core/internal/store/environment_initialization.go index 426a0edca..62adb9d4e 100644 --- a/services/agents-api/internal/store/environment_initialization.go +++ b/services/core/internal/store/environment_initialization.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/environment_initialization_test.go b/services/core/internal/store/environment_initialization_test.go similarity index 94% rename from services/agents-api/internal/store/environment_initialization_test.go rename to services/core/internal/store/environment_initialization_test.go index 32d4af3ac..770e6407f 100644 --- a/services/agents-api/internal/store/environment_initialization_test.go +++ b/services/core/internal/store/environment_initialization_test.go @@ -5,12 +5,12 @@ import ( "context" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" "net/http" "net/http/httptest" @@ -19,7 +19,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func initializationState(t *testing.T, s *store.Store, tenant, environment string) string { diff --git a/services/agents-api/internal/store/environment_input_activity.go b/services/core/internal/store/environment_input_activity.go similarity index 96% rename from services/agents-api/internal/store/environment_input_activity.go rename to services/core/internal/store/environment_input_activity.go index 31fe75d11..22538e80b 100644 --- a/services/agents-api/internal/store/environment_input_activity.go +++ b/services/core/internal/store/environment_input_activity.go @@ -5,8 +5,8 @@ import ( "errors" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/environment_input_activity_test.go b/services/core/internal/store/environment_input_activity_test.go similarity index 100% rename from services/agents-api/internal/store/environment_input_activity_test.go rename to services/core/internal/store/environment_input_activity_test.go diff --git a/services/agents-api/internal/store/environment_input_claim_test.go b/services/core/internal/store/environment_input_claim_test.go similarity index 100% rename from services/agents-api/internal/store/environment_input_claim_test.go rename to services/core/internal/store/environment_input_claim_test.go diff --git a/services/agents-api/internal/store/environment_input_expiry.go b/services/core/internal/store/environment_input_expiry.go similarity index 94% rename from services/agents-api/internal/store/environment_input_expiry.go rename to services/core/internal/store/environment_input_expiry.go index ae968de2d..60c01ce25 100644 --- a/services/agents-api/internal/store/environment_input_expiry.go +++ b/services/core/internal/store/environment_input_expiry.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/environment_input_expiry_test.go b/services/core/internal/store/environment_input_expiry_test.go similarity index 100% rename from services/agents-api/internal/store/environment_input_expiry_test.go rename to services/core/internal/store/environment_input_expiry_test.go diff --git a/services/agents-api/internal/store/environment_input_migration_test.go b/services/core/internal/store/environment_input_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environment_input_migration_test.go rename to services/core/internal/store/environment_input_migration_test.go diff --git a/services/agents-api/internal/store/environment_input_settlement_test.go b/services/core/internal/store/environment_input_settlement_test.go similarity index 100% rename from services/agents-api/internal/store/environment_input_settlement_test.go rename to services/core/internal/store/environment_input_settlement_test.go diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/core/internal/store/environment_inputs.go similarity index 99% rename from services/agents-api/internal/store/environment_inputs.go rename to services/core/internal/store/environment_inputs.go index e3008c7c0..dc702c19b 100644 --- a/services/agents-api/internal/store/environment_inputs.go +++ b/services/core/internal/store/environment_inputs.go @@ -11,7 +11,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) const ( diff --git a/services/agents-api/internal/store/environment_inputs_test.go b/services/core/internal/store/environment_inputs_test.go similarity index 100% rename from services/agents-api/internal/store/environment_inputs_test.go rename to services/core/internal/store/environment_inputs_test.go diff --git a/services/core/internal/store/environment_installation.go b/services/core/internal/store/environment_installation.go new file mode 100644 index 000000000..e6519c00a --- /dev/null +++ b/services/core/internal/store/environment_installation.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrInstallationAuthorization = errors.New("installation authorization is invalid or expired; obtain a new command from the Session") + +// InstallationAuthorization permits claiming one Environment's connect-only key. +// The Environment UUID is reserved as that key's ID. Reissuing an authorization +// never rotates or revives the key, and a retry must prove the same local secret. +type InstallationAuthorization struct { + Principal identity.Principal `json:"principal"` + Environment string `json:"environment_id"` + Version string `json:"version"` + ExpiresAt int64 `json:"expires_at"` +} + +func (s *Store) AuthorizeEnvironmentInstallation(ctx context.Context, principal identity.Principal, environment, version string) (string, int64, error) { + if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { + return "", 0, err + } + if err := activeProject(ctx, s.queries, principal); err != nil { + return "", 0, err + } + claim := InstallationAuthorization{Principal: principal, Environment: environment, Version: version, ExpiresAt: time.Now().Add(30 * time.Minute).Unix()} + payload, err := json.Marshal(claim) + if err != nil { + return "", 0, err + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + signature, err := s.credentialCipher.Fingerprint("environment-installation", encoded) + if err != nil { + return "", 0, err + } + return encoded + "." + signature, claim.ExpiresAt, nil +} + +func (s *Store) ValidateEnvironmentInstallation(ctx context.Context, token, version string) (InstallationAuthorization, error) { + var claim InstallationAuthorization + encoded, signature, ok := strings.Cut(token, ".") + if !ok || len(token) > 4096 { + return claim, ErrInstallationAuthorization + } + want, err := s.credentialCipher.Fingerprint("environment-installation", encoded) + if err != nil || !hmac.Equal([]byte(want), []byte(signature)) { + return claim, ErrInstallationAuthorization + } + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil || json.Unmarshal(payload, &claim) != nil || claim.Version != version || claim.ExpiresAt <= time.Now().Unix() { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + if err := s.selfHostedExecutorTarget(ctx, claim.Principal, claim.Environment); err != nil { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + if err := activeProject(ctx, s.queries, claim.Principal); err != nil { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + return claim, nil +} + +// ClaimEnvironmentInstallation stores only the digest of a secret generated and +// persisted by the installer before this request. A lost HTTP response is safe +// to retry; another machine or a revoked/rotated key cannot claim it again. +func (s *Store) ClaimEnvironmentInstallation(ctx context.Context, token, version, secret string) error { + claim, err := s.ValidateEnvironmentInstallation(ctx, token, version) + if err != nil { + return err + } + decoded, err := base64.RawURLEncoding.DecodeString(secret) + if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != secret { + return ErrInvalidInput + } + principal := claim.Principal + tenant, id, err := executorCredentialIdentity(principal, claim.Environment) + if err != nil { + return err + } + hash := sha256.Sum256([]byte(secret)) + digest := hex.EncodeToString(hash[:]) + return s.withExecutorCredentialTarget(ctx, principal, id, func(ctx context.Context, q *sqlc.Queries) error { + if err := activeProject(ctx, q, principal); err != nil { + return err + } + keys, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{TenantID: tenant, EnvironmentID: id, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}}) + if err != nil { + return err + } + if len(keys) > 0 { + // Existing operator-issued keys must not be replaced by onboarding. + if len(keys) != 1 || keys[0].KeyID != id || keys[0].RevokedAt.Valid { + return ErrExecutorCredentialExists + } + _, err := q.AuthenticateEnvironmentExecutor(ctx, sqlc.AuthenticateEnvironmentExecutorParams{EnvironmentID: id, TokenSha256: digest}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrExecutorCredentialExists + } + return err + } + _, err = q.IssueExecutorCredential(ctx, sqlc.IssueExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, EnvironmentID: id, TokenSha256: digest}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrExecutorCredentialExists + } + return err + }) +} diff --git a/services/core/internal/store/environment_installation_test.go b/services/core/internal/store/environment_installation_test.go new file mode 100644 index 000000000..389ae4c63 --- /dev/null +++ b/services/core/internal/store/environment_installation_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + ctx := t.Context() + project := createTestProject(t, s) + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + p := binding.Principal + input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") + input.Creator = p.Subject() + session, err := s.CreateSession(ctx, p.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, p, environment.ID, "build") + if err != nil || expires <= time.Now().Unix() || expires > time.Now().Add(31*time.Minute).Unix() { + t.Fatal("authorization", err) + } + for _, pair := range [][2]string{{token + "x", "build"}, {token, "other-build"}, {"", "build"}} { + if _, err := s.ValidateEnvironmentInstallation(ctx, pair[0], pair[1]); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("accepted invalid authorization", err) + } + } + payload, _, _ := strings.Cut(token, ".") + raw, _ := base64.RawURLEncoding.DecodeString(payload) + var expired InstallationAuthorization + _ = json.Unmarshal(raw, &expired) + expired.ExpiresAt = time.Now().Add(-time.Second).Unix() + raw, _ = json.Marshal(expired) + payload = base64.RawURLEncoding.EncodeToString(raw) + signature, _ := cipher.Fingerprint("environment-installation", payload) + if _, err := s.ValidateEnvironmentInstallation(ctx, payload+"."+signature, "build"); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("accepted expired grant", err) + } + one, _, _ := newExecutorSecret() + two, _, _ := newExecutorSecret() + secrets := []string{one, two} + results := make([]error, 2) + var wg sync.WaitGroup + for i := range secrets { + wg.Add(1) + go func() { defer wg.Done(); results[i] = s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[i]) }() + } + wg.Wait() + winner := -1 + for i, err := range results { + if err == nil { + if winner >= 0 { + t.Fatal("two machines claimed one Environment") + } + winner = i + } else if !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal(err) + } + } + if winner < 0 { + t.Fatal("no claim succeeded") + } + if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); err != nil { + t.Fatal("lost-response retry", err) + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(secrets[winner])); err != nil { + t.Fatal(err) + } + if err := s.RevokeExecutorCredential(ctx, p, environment.ID); err != nil { + t.Fatal(err) + } + if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("revoked key resurrected", err) + } + if err := s.DeleteSession(ctx, p.TenantID, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.ValidateEnvironmentInstallation(ctx, token, "build"); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("deleted Session grant accepted", err) + } +} diff --git a/services/agents-api/internal/store/environment_mcp_public_test.go b/services/core/internal/store/environment_mcp_public_test.go similarity index 92% rename from services/agents-api/internal/store/environment_mcp_public_test.go rename to services/core/internal/store/environment_mcp_public_test.go index 07d5fa283..001c49545 100644 --- a/services/agents-api/internal/store/environment_mcp_public_test.go +++ b/services/core/internal/store/environment_mcp_public_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestPublicEnvironmentMCPUsesAttachedVaultSelection(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_network_test.go b/services/core/internal/store/environment_network_test.go similarity index 100% rename from services/agents-api/internal/store/environment_network_test.go rename to services/core/internal/store/environment_network_test.go diff --git a/services/agents-api/internal/store/environment_packages_test.go b/services/core/internal/store/environment_packages_test.go similarity index 94% rename from services/agents-api/internal/store/environment_packages_test.go rename to services/core/internal/store/environment_packages_test.go index d557bc48e..c3c128068 100644 --- a/services/agents-api/internal/store/environment_packages_test.go +++ b/services/core/internal/store/environment_packages_test.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/core/internal/store/environment_plugins.go b/services/core/internal/store/environment_plugins.go new file mode 100644 index 000000000..4bae93c3e --- /dev/null +++ b/services/core/internal/store/environment_plugins.go @@ -0,0 +1,57 @@ +package store + +import ( + "encoding/json" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" +) + +const MaxPluginsArchiveBytes = 10 << 20 + +// EnvironmentPlugin separates safe identity from confidential immutable input. +type EnvironmentPlugin struct { + Metadata agentplugin.Metadata `json:"metadata"` + Archive []byte `json:"archive"` +} + +func ValidateEnvironmentPlugins(plugins []EnvironmentPlugin) error { + if len(plugins) > 50 { + return ErrInvalidInput + } + seen := map[string]bool{} + compressed, expanded := 0, 0 + for _, plugin := range plugins { + compressed += len(plugin.Archive) + if compressed > MaxPluginsArchiveBytes || seen[plugin.Metadata.Name] { + return ErrInvalidInput + } + seen[plugin.Metadata.Name] = true + bundle, err := agentplugin.Read(plugin.Archive, plugin.Metadata) + if err != nil { + return ErrInvalidInput + } + for _, file := range bundle.Files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalidInput + } + } + return nil +} + +func (s EnvironmentSetup) PluginMetadata() []agentplugin.Metadata { + result := make([]agentplugin.Metadata, 0, len(s.Plugins)) + for _, plugin := range s.Plugins { + result = append(result, plugin.Metadata) + } + return result +} + +func (s *Store) sealTemplatePlugins(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { + metadata, err := json.Marshal(setup.PluginMetadata()) + if err != nil { + return nil, nil, err + } + contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "plugins", setup.Plugins, len(setup.Plugins) == 0) + return metadata, contents, err +} diff --git a/services/core/internal/store/environment_plugins_test.go b/services/core/internal/store/environment_plugins_test.go new file mode 100644 index 000000000..68f217a74 --- /dev/null +++ b/services/core/internal/store/environment_plugins_test.go @@ -0,0 +1,118 @@ +package store + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{23}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{ + ".codex-plugin/plugin.json": `{"name":"plugin-proof","description":"A proof.","skills":"./skills"}`, + "skills/proof/SKILL.md": "---\nname: proof\ndescription: A proof.\n---\nplugin-private-canary", + "shared/data.txt": "plugin-private-resource", + } { + f, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/" + path, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + setup := EnvironmentSetup{Plugins: []EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive.Bytes()}}, CapabilityDirectories: []string{"/workspace/generated"}} + tenant, foreign := uuid.NewString(), uuid.NewString() + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true, Initialization: setup}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Plugins) != 1 || !public.Initialization.Empty() || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("safe metadata without decryption", err) + } + page, err := New(pool).ListEnvironmentTemplates(t.Context(), tenant, "", 20, false) + if err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { + t.Fatal("list", err) + } + var metadata, encrypted []byte + if err = pool.QueryRow(t.Context(), "SELECT plugins,plugin_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || len(encrypted) == 0 || bytes.Contains(metadata, []byte("private")) || bytes.Contains(encrypted, []byte("private")) { + t.Fatal("plaintext storage", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Plugins, setup.Plugins) { + t.Fatal("resolution", err) + } + for _, read := range []func() error{ + func() error { _, e := s.GetEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, + func() error { _, _, e := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, + func() error { + _, e := s.UpdateEnvironmentTemplate(t.Context(), foreign, template.ID, EnvironmentTemplateInput{SetPlugins: true}) + return e + }, + } { + if err := read(); !errors.Is(err, ErrNotFound) { + t.Fatal("tenant isolation", err) + } + } + request := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + session, err := s.CreateSession(t.Context(), tenant, request) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Environment struct { + Plugins []agentplugin.Metadata `json:"plugins"` + Directories []string `json:"capability_directories"` + } `json:"environment"` + } + if err = json.Unmarshal(session.Configuration, &cfg); err != nil || len(cfg.Environment.Plugins) != 1 || !reflect.DeepEqual(cfg.Environment.Directories, setup.CapabilityDirectories) { + t.Fatal("frozen public metadata", err) + } + name := "renamed" + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(preserved.Initialization.Plugins, setup.Plugins) || !reflect.DeepEqual(preserved.Initialization.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("unrelated update changed installation", err) + } + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true}); err != nil { + t.Fatal(err) + } + cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(cleared.Initialization.Plugins) != 0 || len(cleared.CapabilityDirectories) != 0 { + t.Fatal("clear", err) + } + if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(frozen.Plugins, setup.Plugins) || !reflect.DeepEqual(frozen.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("frozen snapshot changed", err) + } + retry, err := s.CreateSession(t.Context(), tenant, request) + if err != nil || retry.ID != session.ID { + t.Fatal("retry", err) + } + if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign snapshot", err) + } +} diff --git a/services/agents-api/internal/store/environment_retrieve_public_test.go b/services/core/internal/store/environment_retrieve_public_test.go similarity index 93% rename from services/agents-api/internal/store/environment_retrieve_public_test.go rename to services/core/internal/store/environment_retrieve_public_test.go index 1217c651d..c3d7acbc0 100644 --- a/services/agents-api/internal/store/environment_retrieve_public_test.go +++ b/services/core/internal/store/environment_retrieve_public_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_runtime_fixture_test.go b/services/core/internal/store/environment_runtime_fixture_test.go similarity index 96% rename from services/agents-api/internal/store/environment_runtime_fixture_test.go rename to services/core/internal/store/environment_runtime_fixture_test.go index 010546b9f..915d800b3 100644 --- a/services/agents-api/internal/store/environment_runtime_fixture_test.go +++ b/services/core/internal/store/environment_runtime_fixture_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/core/internal/store/environment_setup.go b/services/core/internal/store/environment_setup.go new file mode 100644 index 000000000..b123d4f0c --- /dev/null +++ b/services/core/internal/store/environment_setup.go @@ -0,0 +1,174 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "path" + "regexp" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentSetup is confidential input, never ordinary resource metadata. +// Core freezes it once; the common Runtime initializer executes it in order. +type EnvironmentSetup struct { + Env map[string]string `json:"env,omitempty"` + Commands []SetupCommand `json:"setup_commands,omitempty"` + Packages v1.EnvironmentPackages `json:"packages"` + Skills []EnvironmentSkill `json:"skills,omitempty"` + Plugins []EnvironmentPlugin `json:"plugins,omitempty"` + CapabilityDirectories []string `json:"capability_directories,omitempty"` +} + +type SetupCommand struct { + Command string `json:"command"` + CWD string `json:"cwd,omitempty"` +} + +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (s EnvironmentSetup) Empty() bool { + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 +} + +func (s EnvironmentSetup) Validate() error { + return s.validate(false) +} + +func (s EnvironmentSetup) validate(installed bool) error { + if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateSourceDirectories(s.CapabilityDirectories) != nil { + return ErrInvalidInput + } + ordinary := s + ordinary.Skills = nil + ordinary.Plugins = nil + raw, err := json.Marshal(ordinary) + if err != nil || len(raw) > 512*1024 { + return ErrInvalidInput + } + for name, value := range s.Env { + // The first three reservations are explicitly part of the public guide; + // OAC_* identifies the actual deployment authority and binding. + if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "OAC_") || strings.ContainsRune(value, 0) { + return ErrInvalidInput + } + } + for _, command := range s.Commands { + if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { + return ErrInvalidInput + } + } + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { + for _, item := range packages { + if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { + return ErrInvalidInput + } + } + } + return nil +} + +func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { + if empty { + return nil, nil + } + plaintext, err := json.Marshal(input) + if err != nil { + return nil, err + } + return s.credentialCipher.SealEnvironmentSetup(plaintext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) +} + +func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphertext []byte, output any) error { + if len(ciphertext) == 0 { + return nil + } + plaintext, err := s.credentialCipher.OpenEnvironmentSetup(ciphertext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) + if err != nil { + return err + } + if decodeSetupJSON(plaintext, output) != nil { + return ErrInvalidInput + } + return nil +} + +func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { + if err := setup.validate(true); err != nil { + return err + } + if setup.Empty() { + return nil + } + owner, err := parseID(tenant) + if err != nil { + return err + } + encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup, false) + if err != nil { + return err + } + return q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: session, Contents: encrypted}) +} + +func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (EnvironmentSetup, error) { + var result EnvironmentSetup + lookup, err := deviceLookup(tenant, session) + if err != nil { + return result, ErrNotFound + } + encrypted, err := s.queries.GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return result, ErrNotFound + } + if err != nil { + return result, err + } + if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { + return result, err + } + return result, result.validate(true) +} + +func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { + result := s.Packages + if result.NPM == nil { + result.NPM = []string{} + } + if result.Python == nil { + result.Python = []string{} + } + return result +} + +func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, []byte, error) { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return nil, nil, nil, err + } + env, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "env", setup.Env, len(setup.Env) == 0) + if err != nil { + return nil, nil, nil, err + } + commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) + return packages, env, commands, err +} + +// decodeSetupJSON rejects removed configuration fields instead of silently dropping them. +func decodeSetupJSON(data []byte, output any) error { + if !json.Valid(data) { + return ErrInvalidInput + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(output) +} diff --git a/services/agents-api/internal/store/environment_setup_test.go b/services/core/internal/store/environment_setup_test.go similarity index 97% rename from services/agents-api/internal/store/environment_setup_test.go rename to services/core/internal/store/environment_setup_test.go index 3e957e9b5..6e7fc3b4e 100644 --- a/services/agents-api/internal/store/environment_setup_test.go +++ b/services/core/internal/store/environment_setup_test.go @@ -7,8 +7,8 @@ import ( "reflect" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_skill_references.go b/services/core/internal/store/environment_skill_references.go similarity index 97% rename from services/agents-api/internal/store/environment_skill_references.go rename to services/core/internal/store/environment_skill_references.go index 89ba8679a..1cc8e31d8 100644 --- a/services/agents-api/internal/store/environment_skill_references.go +++ b/services/core/internal/store/environment_skill_references.go @@ -6,7 +6,7 @@ import ( "sort" "strconv" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/environment_skill_references_test.go b/services/core/internal/store/environment_skill_references_test.go similarity index 98% rename from services/agents-api/internal/store/environment_skill_references_test.go rename to services/core/internal/store/environment_skill_references_test.go index 380ede283..eb6ea4ea2 100644 --- a/services/agents-api/internal/store/environment_skill_references_test.go +++ b/services/core/internal/store/environment_skill_references_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/core/internal/store/environment_skills.go b/services/core/internal/store/environment_skills.go new file mode 100644 index 000000000..1033f7b92 --- /dev/null +++ b/services/core/internal/store/environment_skills.go @@ -0,0 +1,116 @@ +package store + +import ( + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" +) + +// EnvironmentSkillMetadata is either template intent or concrete installed metadata. +// References acquire their name, description and concrete version at Session commit. +type EnvironmentSkillMetadata struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + SkillID string `json:"skill_id,omitempty"` + Version string `json:"version,omitempty"` +} + +// EnvironmentSkill keeps confidential content separate from public metadata. +// A template reference has no archive; a committed Session always has frozen bytes. +type EnvironmentSkill struct { + Metadata EnvironmentSkillMetadata `json:"metadata"` + Archive []byte `json:"archive,omitempty"` +} + +// InstallationMetadata removes public reference identity at the installer boundary. +func (s EnvironmentSkill) InstallationMetadata() agentskill.Metadata { + return agentskill.Metadata{Type: "inline", Name: s.Metadata.Name, Description: s.Metadata.Description} +} + +const MaxSkillsArchiveBytes = 10 << 20 + +func ValidateEnvironmentSkills(skills []EnvironmentSkill) error { + return validateEnvironmentSkills(skills, false) +} + +func ValidateInstalledSkillMetadata(metadata EnvironmentSkillMetadata) error { + if metadata.Name == "" || metadata.Description == "" { + return ErrInvalidInput + } + switch metadata.Type { + case "inline": + if metadata.SkillID != "" || metadata.Version != "" { + return ErrInvalidInput + } + case "skill_reference": + if metadata.SkillID == "" { + return ErrInvalidInput + } + if _, err := skillVersionNumber(metadata.Version); err != nil { + return err + } + default: + return ErrInvalidInput + } + return nil +} + +func validateEnvironmentSkills(skills []EnvironmentSkill, installed bool) error { + if len(skills) > 50 { + return ErrInvalidInput + } + seen := map[string]bool{} + total := 0 + expanded := 0 + for _, skill := range skills { + if !installed && skill.Metadata.Type == "skill_reference" { + m := skill.Metadata + if m.SkillID == "" || len(m.SkillID) > 256 || m.Name != "" || m.Description != "" || len(skill.Archive) != 0 { + return ErrInvalidInput + } + if m.Version != "" && m.Version != "latest" { + if _, err := skillVersionNumber(m.Version); err != nil { + return err + } + } + continue + } + if err := ValidateInstalledSkillMetadata(skill.Metadata); err != nil { + return err + } + total += len(skill.Archive) + if total > MaxSkillsArchiveBytes || seen[skill.Metadata.Name] { + return ErrInvalidInput + } + seen[skill.Metadata.Name] = true + files, err := agentskill.Read(skill.Archive, skill.InstallationMetadata()) + if err != nil { + return ErrInvalidInput + } + for _, file := range files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalidInput + } + } + return nil +} + +func (s EnvironmentSetup) SkillMetadata() []EnvironmentSkillMetadata { + result := make([]EnvironmentSkillMetadata, 0, len(s.Skills)) + for _, skill := range s.Skills { + result = append(result, skill.Metadata) + } + return result +} + +func (s *Store) sealTemplateSkills(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { + metadata, err := json.Marshal(setup.SkillMetadata()) + if err != nil { + return nil, nil, err + } + contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "skills", setup.Skills, len(setup.Skills) == 0) + return metadata, contents, err +} diff --git a/services/core/internal/store/environment_skills_test.go b/services/core/internal/store/environment_skills_test.go new file mode 100644 index 000000000..37e02cf87 --- /dev/null +++ b/services/core/internal/store/environment_skills_test.go @@ -0,0 +1,82 @@ +package store + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + header := &zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store} + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\nprivate-skill-canary")); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + setup := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} + tenant, foreign := uuid.NewString(), uuid.NewString() + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Skills) != 1 || !public.Initialization.Empty() { + t.Fatal("public metadata", err) + } + var metadata, encrypted []byte + if err = pool.QueryRow(t.Context(), "SELECT skills,skill_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || bytes.Contains(metadata, []byte("canary")) || bytes.Contains(encrypted, []byte("canary")) { + t.Fatal("plaintext storage", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Skills, setup.Skills) { + t.Fatal("resolution", err) + } + if _, _, err = s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("tenant isolation", err) + } + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization}) + if err != nil { + t.Fatal(err) + } + name := "renamed" + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(preserved.Initialization.Skills, setup.Skills) { + t.Fatal("unrelated update", err) + } + cleared, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetSkills: true}) + if err != nil || len(cleared.Skills) != 0 { + t.Fatal("clearing", err) + } + if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(frozen.Skills, setup.Skills) { + t.Fatal("frozen content changed", err) + } + if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign content", err) + } +} diff --git a/services/agents-api/internal/store/environment_steering_order_test.go b/services/core/internal/store/environment_steering_order_test.go similarity index 100% rename from services/agents-api/internal/store/environment_steering_order_test.go rename to services/core/internal/store/environment_steering_order_test.go diff --git a/services/core/internal/store/environment_templates.go b/services/core/internal/store/environment_templates.go new file mode 100644 index 000000000..f0faa5920 --- /dev/null +++ b/services/core/internal/store/environment_templates.go @@ -0,0 +1,243 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "time" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentTemplate is configuration ownership, independent of provider images. + +type EnvironmentTemplate struct { + Plugins []agentplugin.Metadata + CapabilityDirectories []string + Skills []EnvironmentSkillMetadata + Packages v1.EnvironmentPackages + Initialization EnvironmentSetup + Files []InitialFileMetadata + ID string + Name *string + NetworkAccess string + AllowedDomains []string + CreatedAt time.Time + UpdatedAt time.Time +} + +type EnvironmentTemplateInput struct { + Initialization EnvironmentSetup + SetEnv, SetSetup, SetPackages, SetSkills, SetPlugins, SetDirectories bool + Files []InitialFile + SetFiles bool + Name *string + SetName bool + NetworkAccess string + AllowedDomains []string + SetNetwork bool +} + +func (in EnvironmentTemplateInput) valid() bool { + return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && + (!in.SetNetwork || (agentnetwork.Policy{Access: in.NetworkAccess, AllowedDomains: in.AllowedDomains}).Validate() == nil) +} + +type templateMetadataRow sqlc.GetEnvironmentTemplateRow + +func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, error) { + if errors.Is(err, pgx.ErrNoRows) { + return EnvironmentTemplate{}, ErrNotFound + } + if err != nil { + return EnvironmentTemplate{}, err + } + result := EnvironmentTemplate{CapabilityDirectories: append([]string{}, row.CapabilityDirectories...), ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + if row.Name.Valid { + result.Name = &row.Name.String + } + if json.Unmarshal(row.Files, &result.Files) != nil || decodeSetupJSON(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { + return EnvironmentTemplate{}, ErrInvalidInput + } + return result, nil +} + +func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.SetNetwork { + in.NetworkAccess = "enabled" + in.AllowedDomains = nil + in.SetNetwork = true + } + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + id := uuid.New() + metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), id.String(), in.Files) + if err != nil { + return EnvironmentTemplate{}, err + } + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + var result EnvironmentTemplate + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) + result, err = templateFromRow(templateMetadataRow(row), err) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "environment_template", result.ID, "", AuditResource{Type: "environment_template", ID: result.ID}) + }) + return result, err +} + +func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + id, err := parseID(templateID) + if err != nil { + return EnvironmentTemplate{}, ErrNotFound + } + row, err := s.queries.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: id}) + return templateFromRow(templateMetadataRow(row), err) +} + +// Each supplied field replaces atomically, preserving concurrent unrelated updates. +func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templateID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + // Sealing runs before the update lookup, so a malformed ID must take the same path. + id := parsePathID(templateID) + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + metadata, encrypted, err := s.sealTemplateFiles(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Files) + if err != nil { + return EnvironmentTemplate{}, err + } + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + plugins, pluginContents, err := s.sealTemplatePlugins(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + var result EnvironmentTemplate + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) + result, err = templateFromRow(templateMetadataRow(row), err) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "environment_template", result.ID, "") + }) + return result, err +} + +func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", err + } + id, err := parseID(templateID) + if err != nil { + return "", ErrNotFound + } + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + result, err := q.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(result.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "environment_template", deletedID, "") + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + return deletedID, nil +} + +type EnvironmentTemplatePage struct { + Templates []EnvironmentTemplate + HasMore bool +} + +func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (EnvironmentTemplatePage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplatePage{}, err + } + if limit < 1 || limit > 100 { + return EnvironmentTemplatePage{}, ErrInvalidInput + } + params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetEnvironmentTemplate(ctx, tenantID, lookupCursor(cursor)) + if err != nil { + return EnvironmentTemplatePage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListEnvironmentTemplates(ctx, params) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page := EnvironmentTemplatePage{Templates: make([]EnvironmentTemplate, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if len(rows) > limit { + rows = rows[:limit] + } + for _, row := range rows { + value, err := templateFromRow(templateMetadataRow(row), nil) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page.Templates = append(page.Templates, value) + } + return page, nil +} diff --git a/services/agents-api/internal/store/environment_templates_noop_test.go b/services/core/internal/store/environment_templates_noop_test.go similarity index 96% rename from services/agents-api/internal/store/environment_templates_noop_test.go rename to services/core/internal/store/environment_templates_noop_test.go index b90dce123..217b37bb3 100644 --- a/services/agents-api/internal/store/environment_templates_noop_test.go +++ b/services/core/internal/store/environment_templates_noop_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_templates_test.go b/services/core/internal/store/environment_templates_test.go similarity index 100% rename from services/agents-api/internal/store/environment_templates_test.go rename to services/core/internal/store/environment_templates_test.go diff --git a/services/agents-api/internal/store/environment_work_test.go b/services/core/internal/store/environment_work_test.go similarity index 97% rename from services/agents-api/internal/store/environment_work_test.go rename to services/core/internal/store/environment_work_test.go index 3df60457d..b3024194e 100644 --- a/services/agents-api/internal/store/environment_work_test.go +++ b/services/core/internal/store/environment_work_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/core/internal/store/environment_worker_helpers_test.go similarity index 94% rename from services/agents-api/internal/store/environment_worker_helpers_test.go rename to services/core/internal/store/environment_worker_helpers_test.go index ffacdc639..97a45d3aa 100644 --- a/services/agents-api/internal/store/environment_worker_helpers_test.go +++ b/services/core/internal/store/environment_worker_helpers_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/environment_worker_scan_test.go b/services/core/internal/store/environment_worker_scan_test.go similarity index 96% rename from services/agents-api/internal/store/environment_worker_scan_test.go rename to services/core/internal/store/environment_worker_scan_test.go index e2f1bd770..ee7b76ccf 100644 --- a/services/agents-api/internal/store/environment_worker_scan_test.go +++ b/services/core/internal/store/environment_worker_scan_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerEnvironmentRetriesNewlyReadyAtNextScan(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_worker_test.go b/services/core/internal/store/environment_worker_test.go similarity index 98% rename from services/agents-api/internal/store/environment_worker_test.go rename to services/core/internal/store/environment_worker_test.go index 313cf2cb6..37133ef77 100644 --- a/services/agents-api/internal/store/environment_worker_test.go +++ b/services/core/internal/store/environment_worker_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { diff --git a/services/agents-api/internal/store/environment_write_audit_test.go b/services/core/internal/store/environment_write_audit_test.go similarity index 98% rename from services/agents-api/internal/store/environment_write_audit_test.go rename to services/core/internal/store/environment_write_audit_test.go index e6852c69c..db078cd02 100644 --- a/services/agents-api/internal/store/environment_write_audit_test.go +++ b/services/core/internal/store/environment_write_audit_test.go @@ -8,7 +8,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" ) func TestEnvironmentUploadWriteAuditSurvivesRequestAndLeaseContext(t *testing.T) { diff --git a/services/core/internal/store/environments.go b/services/core/internal/store/environments.go new file mode 100644 index 000000000..a61b42413 --- /dev/null +++ b/services/core/internal/store/environments.go @@ -0,0 +1,89 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +// Environment retains execution ownership; its configuration is an internal snapshot, not a public response. +type Environment struct { + Initialization string + ID string + SessionID string + TenantID string + Status string + CreatedAt time.Time + Configuration json.RawMessage +} + +func createSessionEnvironment(ctx context.Context, q *sqlc.Queries, session sqlc.Session) error { + var snapshot struct { + Environment *struct { + Type string `json:"type"` + } `json:"environment"` + } + if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { + return fmt.Errorf("%w: invalid environment configuration", ErrInvalidInput) + } + if snapshot.Environment == nil || snapshot.Environment.Type == "none" { + return nil + } + switch snapshot.Environment.Type { + case "self_hosted", "openai_hosted": + return q.CreateEnvironment(ctx, sqlc.CreateEnvironmentParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session.ID, + }) + default: + return fmt.Errorf("%w: unsupported environment type", ErrInvalidInput) + } +} + +func (s *Store) GetEnvironment(ctx context.Context, tenantID, environmentID string) (Environment, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Environment{}, err + } + id := parsePathID(environmentID) + row, err := s.queries.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenant, ID: id}) + return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) +} + +func (s *Store) GetSessionEnvironment(ctx context.Context, tenantID, sessionID string) (Environment, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Environment{}, err + } + id, err := parseID(sessionID) + if err != nil { + return Environment{}, err + } + row, err := s.queries.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: id}) + return environmentFromRow(row.Environment, row.TenantID, row.Configuration, err) +} + +func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration []byte, err error) (Environment, error) { + if errors.Is(err, pgx.ErrNoRows) { + return Environment{}, ErrNotFound + } + if err != nil { + return Environment{}, fmt.Errorf("get environment: %w", err) + } + configuration, err = canonicalJSONObject(configuration) + if err != nil { + return Environment{}, fmt.Errorf("decode environment configuration: %w", err) + } + return Environment{ + ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), + TenantID: uuid.UUID(tenant.Bytes).String(), Status: row.Status, + Initialization: row.Initialization, CreatedAt: row.CreatedAt.Time, Configuration: configuration, + }, nil +} diff --git a/services/agents-api/internal/store/environments_migration_test.go b/services/core/internal/store/environments_migration_test.go similarity index 100% rename from services/agents-api/internal/store/environments_migration_test.go rename to services/core/internal/store/environments_migration_test.go diff --git a/services/agents-api/internal/store/environments_test.go b/services/core/internal/store/environments_test.go similarity index 100% rename from services/agents-api/internal/store/environments_test.go rename to services/core/internal/store/environments_test.go diff --git a/services/agents-api/internal/store/execution_cancellation_test.go b/services/core/internal/store/execution_cancellation_test.go similarity index 100% rename from services/agents-api/internal/store/execution_cancellation_test.go rename to services/core/internal/store/execution_cancellation_test.go diff --git a/services/agents-api/internal/store/execution_events_test.go b/services/core/internal/store/execution_events_test.go similarity index 95% rename from services/agents-api/internal/store/execution_events_test.go rename to services/core/internal/store/execution_events_test.go index 31bef15ff..a671df488 100644 --- a/services/agents-api/internal/store/execution_events_test.go +++ b/services/core/internal/store/execution_events_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { diff --git a/services/agents-api/internal/store/execution_lease.go b/services/core/internal/store/execution_lease.go similarity index 98% rename from services/agents-api/internal/store/execution_lease.go rename to services/core/internal/store/execution_lease.go index f7d502c8c..31a669e6b 100644 --- a/services/agents-api/internal/store/execution_lease.go +++ b/services/core/internal/store/execution_lease.go @@ -8,7 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) const executionTransactionTimeout = 5 * time.Second diff --git a/services/agents-api/internal/store/execution_lease_cleanup_test.go b/services/core/internal/store/execution_lease_cleanup_test.go similarity index 100% rename from services/agents-api/internal/store/execution_lease_cleanup_test.go rename to services/core/internal/store/execution_lease_cleanup_test.go diff --git a/services/agents-api/internal/store/execution_lease_handoff_test.go b/services/core/internal/store/execution_lease_handoff_test.go similarity index 100% rename from services/agents-api/internal/store/execution_lease_handoff_test.go rename to services/core/internal/store/execution_lease_handoff_test.go diff --git a/services/agents-api/internal/store/execution_lease_test.go b/services/core/internal/store/execution_lease_test.go similarity index 99% rename from services/agents-api/internal/store/execution_lease_test.go rename to services/core/internal/store/execution_lease_test.go index 5291b7392..975f66cb3 100644 --- a/services/agents-api/internal/store/execution_lease_test.go +++ b/services/core/internal/store/execution_lease_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/execution_messages_test.go b/services/core/internal/store/execution_messages_test.go similarity index 94% rename from services/agents-api/internal/store/execution_messages_test.go rename to services/core/internal/store/execution_messages_test.go index e240151f0..56ee5698a 100644 --- a/services/agents-api/internal/store/execution_messages_test.go +++ b/services/core/internal/store/execution_messages_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { diff --git a/services/agents-api/internal/store/execution_tools_test.go b/services/core/internal/store/execution_tools_test.go similarity index 95% rename from services/agents-api/internal/store/execution_tools_test.go rename to services/core/internal/store/execution_tools_test.go index a8bd2e077..f3525c455 100644 --- a/services/agents-api/internal/store/execution_tools_test.go +++ b/services/core/internal/store/execution_tools_test.go @@ -7,8 +7,8 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { diff --git a/services/agents-api/internal/store/executor_credential_target.go b/services/core/internal/store/executor_credential_target.go similarity index 93% rename from services/agents-api/internal/store/executor_credential_target.go rename to services/core/internal/store/executor_credential_target.go index c574fb774..e7241f686 100644 --- a/services/agents-api/internal/store/executor_credential_target.go +++ b/services/core/internal/store/executor_credential_target.go @@ -8,8 +8,8 @@ import ( "encoding/hex" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/executor_fixture_test.go b/services/core/internal/store/executor_fixture_test.go similarity index 97% rename from services/agents-api/internal/store/executor_fixture_test.go rename to services/core/internal/store/executor_fixture_test.go index ef8712387..6c304fd0e 100644 --- a/services/agents-api/internal/store/executor_fixture_test.go +++ b/services/core/internal/store/executor_fixture_test.go @@ -1,7 +1,7 @@ package store_test import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/executor_principals_migration_test.go b/services/core/internal/store/executor_principals_migration_test.go similarity index 100% rename from services/agents-api/internal/store/executor_principals_migration_test.go rename to services/core/internal/store/executor_principals_migration_test.go diff --git a/services/agents-api/internal/store/executor_principals_test.go b/services/core/internal/store/executor_principals_test.go similarity index 98% rename from services/agents-api/internal/store/executor_principals_test.go rename to services/core/internal/store/executor_principals_test.go index 6559ad95f..7f453761d 100644 --- a/services/agents-api/internal/store/executor_principals_test.go +++ b/services/core/internal/store/executor_principals_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/executor_recovery_test.go b/services/core/internal/store/executor_recovery_test.go similarity index 95% rename from services/agents-api/internal/store/executor_recovery_test.go rename to services/core/internal/store/executor_recovery_test.go index eba3602cb..17160f043 100644 --- a/services/agents-api/internal/store/executor_recovery_test.go +++ b/services/core/internal/store/executor_recovery_test.go @@ -3,8 +3,8 @@ package store_test import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/export_test.go b/services/core/internal/store/export_test.go new file mode 100644 index 000000000..dbda66773 --- /dev/null +++ b/services/core/internal/store/export_test.go @@ -0,0 +1,82 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/jackc/pgx/v5/pgxpool" + "testing" +) + +func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) } + +var fixtureCipher, _ = credentialcrypto.New(bytes.Repeat([]byte{61}, 32)) + +// FixtureCipher is the credential key of NewModelTestStore, for reopened stores. +func FixtureCipher() *credentialcrypto.Cipher { return fixtureCipher } + +// NewModelTestStore is NewTestStore with a fixture credential key, so Sessions +// can freeze a model provider. +func NewModelTestStore(t *testing.T) (*Store, *pgxpool.Pool) { + _, pool := testStore(t) + return NewWithCredentialCipher(pool, fixtureCipher), pool +} + +// FixtureModelProvider is a valid bundle for the harness. Hosted and self-hosted +// Sessions cannot run without one, so fixtures supply it instead of relaxing +// that check. +func FixtureModelProvider(harness string) *v1.ModelProviderInput { + if harness == "codex" { + return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} + } + return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} +} + +// WithFixtureModelProvider adds the fixture provider, as a Session-supplied +// bundle, to a hosted or self-hosted creation that has none. The store must +// have a credential key; other inputs are returned unchanged. +func WithFixtureModelProvider(input CreateSessionInput) CreateSessionInput { + var configuration struct { + Environment struct { + Type string `json:"type"` + } `json:"environment"` + } + if input.ModelProvider != nil || json.Unmarshal(input.Configuration, &configuration) != nil || !v1.ModelProviderRequired(configuration.Environment.Type) { + return input + } + input.ModelProvider, input.ModelProviderSource = FixtureModelProvider(input.Engine), v1.ModelProviderSourceSession + if input.ExecutionConfiguration != nil { + projection := *input.ExecutionConfiguration + projection.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration = &projection + } + return input +} + +// FixtureCreator is an explicit synthetic principal for newly created test Sessions. +func FixtureCreator() identity.Subject { + return identity.Subject{Kind: "service_account", ID: "test-runner"} +} + +// FixtureExecutorPrincipal explicitly provisions a synthetic project for executor fixtures. +func FixtureExecutorPrincipal(t *testing.T, s *Store, tenant string) identity.Principal { + t.Helper() + p := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant, OrganizationID: "test-org", ProjectID: tenant}, SubjectKind: FixtureCreator().Kind, SubjectID: FixtureCreator().ID} + if err := s.EnsureProjectScopes(t.Context(), []identity.ProjectScope{p.ProjectScope}); err != nil { + t.Fatal(err) + } + return p +} + +// SkillArchive builds a minimal valid Skill archive for public HTTP fixtures. +func SkillArchive(t *testing.T, marker string) []byte { return skillArchive(t, marker) } + +// CommitLegacyDeletion commits a deletion marker the way releases before the +// idle-only deletion rule did, for Sessions that public deletion now rejects. +func (s *Store) CommitLegacyDeletion(ctx context.Context, tenantID, sessionID string) error { + return s.commitLegacyDeletion(ctx, tenantID, sessionID) +} diff --git a/services/agents-api/internal/store/file_resource_semantics_public_test.go b/services/core/internal/store/file_resource_semantics_public_test.go similarity index 87% rename from services/agents-api/internal/store/file_resource_semantics_public_test.go rename to services/core/internal/store/file_resource_semantics_public_test.go index a2486ca7e..95fb493f8 100644 --- a/services/agents-api/internal/store/file_resource_semantics_public_test.go +++ b/services/core/internal/store/file_resource_semantics_public_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_calls.go b/services/core/internal/store/function_calls.go similarity index 98% rename from services/agents-api/internal/store/function_calls.go rename to services/core/internal/store/function_calls.go index 310f378b4..bdfb4ba0e 100644 --- a/services/agents-api/internal/store/function_calls.go +++ b/services/core/internal/store/function_calls.go @@ -6,7 +6,7 @@ import ( "errors" "strings" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/function_calls_test.go b/services/core/internal/store/function_calls_test.go similarity index 100% rename from services/agents-api/internal/store/function_calls_test.go rename to services/core/internal/store/function_calls_test.go diff --git a/services/agents-api/internal/store/function_execution_native_test.go b/services/core/internal/store/function_execution_native_test.go similarity index 97% rename from services/agents-api/internal/store/function_execution_native_test.go rename to services/core/internal/store/function_execution_native_test.go index 75224a2ed..86811241a 100644 --- a/services/agents-api/internal/store/function_execution_native_test.go +++ b/services/core/internal/store/function_execution_native_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestNativeFunctionExecutionPersistsCallsResultsAndContinuity(t *testing.T) { diff --git a/services/agents-api/internal/store/function_execution_test.go b/services/core/internal/store/function_execution_test.go similarity index 97% rename from services/agents-api/internal/store/function_execution_test.go rename to services/core/internal/store/function_execution_test.go index 9f54808f2..19feffd7b 100644 --- a/services/agents-api/internal/store/function_execution_test.go +++ b/services/core/internal/store/function_execution_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) const functionConfiguration = `{"agent":{"model":"gpt-5.5","tools":[{"type":"function","name":"lookup_ticket","description":"Read a synthetic ticket","parameters":{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false},"defer_loading":false}]},"environment":{"type":"none"}}` diff --git a/services/agents-api/internal/store/function_images_native_test.go b/services/core/internal/store/function_images_native_test.go similarity index 93% rename from services/agents-api/internal/store/function_images_native_test.go rename to services/core/internal/store/function_images_native_test.go index c337ef52d..ad8d07e84 100644 --- a/services/agents-api/internal/store/function_images_native_test.go +++ b/services/core/internal/store/function_images_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_input_execution_test.go b/services/core/internal/store/function_input_execution_test.go similarity index 94% rename from services/agents-api/internal/store/function_input_execution_test.go rename to services/core/internal/store/function_input_execution_test.go index a932a988c..ad347cd89 100644 --- a/services/agents-api/internal/store/function_input_execution_test.go +++ b/services/core/internal/store/function_input_execution_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { diff --git a/services/core/internal/store/function_inputs.go b/services/core/internal/store/function_inputs.go new file mode 100644 index 000000000..c25ddb91c --- /dev/null +++ b/services/core/internal/store/function_inputs.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// Result targets are resolved only inside a tenant-owned Session, after its +// lookup, so a missing or foreign Session still returns ErrNotFound (EVT-11). +var ( + // ErrUnknownFunctionCall rejects a result whose call_id names no function + // call in the Session. + ErrUnknownFunctionCall = errors.New("unknown pending tool call") + // ErrFunctionCallTurnMismatch rejects a result whose call exists in the + // Session but not in the named Turn, including a malformed or unknown Turn. + ErrFunctionCallTurnMismatch = errors.New("tool call belongs to a different turn") +) + +// FunctionResultInput identifies a persisted call; Result is validated by the API. +// It is an internal command, not an upstream input event. TurnID is the caller's +// value and is resolved within the Session at admission. +type FunctionResultInput struct { + TurnID string `json:"turn_id"` + CallID string `json:"call_id"` + Result json.RawMessage `json:"result"` +} + +func functionInput(raw json.RawMessage) (FunctionResultInput, error) { + var input FunctionResultInput + if json.Unmarshal(raw, &input) != nil || input.TurnID == "" || !validFunctionIdentity(input.CallID) || len(input.Result) == 0 { + return input, ErrInvalidInput + } + result, err := canonicalJSONObject(input.Result) + if err != nil { + return input, err + } + input.Result = result + return input, nil +} + +func admitFunctionResult(ctx context.Context, q *sqlc.Queries, tenantID string, session pgtype.UUID, key string, position int32, input Input) (InputReceipt, error) { + result, err := functionInput(input.Payload) + if err != nil { + return InputReceipt{}, err + } + tenant, err := parseID(tenantID) + if err != nil { + return InputReceipt{}, err + } + var turn sqlc.Turn + found := false + // A malformed Turn ID cannot name a Turn; it is classified like an unknown one. + if id, err := parseID(result.TurnID); err == nil { + turn, err = q.GetTurn(ctx, sqlc.GetTurnParams{TenantID: tenant, SessionID: session, ID: id}) + if err == nil { + found = true + } else if !errors.Is(err, pgx.ErrNoRows) { + return InputReceipt{}, err + } + } + if found { + err = storeFunctionResult(ctx, q, turn, result.CallID, result.Result) + if errors.Is(err, ErrNotFound) { + found = false + } else if err != nil { + return InputReceipt{}, err + } + } + if !found { + return InputReceipt{}, unknownFunctionResultTarget(ctx, q, session, result.CallID) + } + sequence, err := q.CreateTurnInput(ctx, sqlc.CreateTurnInputParams{ + SessionID: session, TurnID: turn.ID, IdempotencyKey: key, Kind: input.Kind, Payload: input.Payload, BatchPosition: position, + }) + if err != nil { + return InputReceipt{}, err + } + return inputReceipt(sequence, turn.ID, false), nil +} + +// unknownFunctionResultTarget classifies a result whose Turn does not own the +// call. The answer depends only on this Session's own calls. +func unknownFunctionResultTarget(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, callID string) error { + elsewhere, err := q.SessionHasFunctionCall(ctx, sqlc.SessionHasFunctionCallParams{SessionID: session, CallID: callID}) + if err != nil { + return err + } + if elsewhere { + return ErrFunctionCallTurnMismatch + } + return ErrUnknownFunctionCall +} diff --git a/services/agents-api/internal/store/function_inputs_public_test.go b/services/core/internal/store/function_inputs_public_test.go similarity index 95% rename from services/agents-api/internal/store/function_inputs_public_test.go rename to services/core/internal/store/function_inputs_public_test.go index 319ed4cf2..95fb07450 100644 --- a/services/agents-api/internal/store/function_inputs_public_test.go +++ b/services/core/internal/store/function_inputs_public_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_inputs_test.go b/services/core/internal/store/function_inputs_test.go similarity index 100% rename from services/agents-api/internal/store/function_inputs_test.go rename to services/core/internal/store/function_inputs_test.go diff --git a/services/agents-api/internal/store/function_item_events_test.go b/services/core/internal/store/function_item_events_test.go similarity index 98% rename from services/agents-api/internal/store/function_item_events_test.go rename to services/core/internal/store/function_item_events_test.go index 4566d655a..a6322cee6 100644 --- a/services/agents-api/internal/store/function_item_events_test.go +++ b/services/core/internal/store/function_item_events_test.go @@ -2,7 +2,7 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "reflect" "testing" ) diff --git a/services/agents-api/internal/store/function_model_test.go b/services/core/internal/store/function_model_test.go similarity index 100% rename from services/agents-api/internal/store/function_model_test.go rename to services/core/internal/store/function_model_test.go diff --git a/services/agents-api/internal/store/function_public_native_test.go b/services/core/internal/store/function_public_native_test.go similarity index 91% rename from services/agents-api/internal/store/function_public_native_test.go rename to services/core/internal/store/function_public_native_test.go index 2c74a8664..39ca911ae 100644 --- a/services/agents-api/internal/store/function_public_native_test.go +++ b/services/core/internal/store/function_public_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_results.go b/services/core/internal/store/function_results.go similarity index 97% rename from services/agents-api/internal/store/function_results.go rename to services/core/internal/store/function_results.go index 21c5ce7b6..583e6ecf2 100644 --- a/services/agents-api/internal/store/function_results.go +++ b/services/core/internal/store/function_results.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/function_state.go b/services/core/internal/store/function_state.go similarity index 94% rename from services/agents-api/internal/store/function_state.go rename to services/core/internal/store/function_state.go index c8e759028..4a5f23ffa 100644 --- a/services/agents-api/internal/store/function_state.go +++ b/services/core/internal/store/function_state.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_state_public_test.go b/services/core/internal/store/function_state_public_test.go similarity index 94% rename from services/agents-api/internal/store/function_state_public_test.go rename to services/core/internal/store/function_state_public_test.go index f7e74b43b..d46a7081f 100644 --- a/services/agents-api/internal/store/function_state_public_test.go +++ b/services/core/internal/store/function_state_public_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/function_state_test.go b/services/core/internal/store/function_state_test.go similarity index 100% rename from services/agents-api/internal/store/function_state_test.go rename to services/core/internal/store/function_state_test.go diff --git a/services/agents-api/internal/store/function_stream_native_test.go b/services/core/internal/store/function_stream_native_test.go similarity index 96% rename from services/agents-api/internal/store/function_stream_native_test.go rename to services/core/internal/store/function_stream_native_test.go index 00659f9c6..c21975557 100644 --- a/services/agents-api/internal/store/function_stream_native_test.go +++ b/services/core/internal/store/function_stream_native_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestNativePublicFunctionStreamHelper(t *testing.T) { diff --git a/services/agents-api/internal/store/function_worker_test.go b/services/core/internal/store/function_worker_test.go similarity index 95% rename from services/agents-api/internal/store/function_worker_test.go rename to services/core/internal/store/function_worker_test.go index db8e9cc3a..f964c9c48 100644 --- a/services/agents-api/internal/store/function_worker_test.go +++ b/services/core/internal/store/function_worker_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/harness_onboarding_test.go b/services/core/internal/store/harness_onboarding_test.go similarity index 94% rename from services/agents-api/internal/store/harness_onboarding_test.go rename to services/core/internal/store/harness_onboarding_test.go index 543387313..34b75b483 100644 --- a/services/agents-api/internal/store/harness_onboarding_test.go +++ b/services/core/internal/store/harness_onboarding_test.go @@ -15,13 +15,13 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -177,7 +177,7 @@ func startOnboardingPeer(t *testing.T, h *dispatchHarness) (<-chan proto.PromptR t.Fatal(err) } binary := filepath.Join(t.TempDir(), "onboarding") - build := exec.Command(filepath.Join(goruntime.GOROOT(), "bin", "go"), "build", "-o", binary, "./apps/parsar-daemon/testdata/onboarding") + build := exec.Command(filepath.Join(goruntime.GOROOT(), "bin", "go"), "build", "-o", binary, "./apps/daemon/testdata/onboarding") build.Dir = root if out, err := build.CombinedOutput(); err != nil { t.Fatalf("build fixture: %v %s", err, out) diff --git a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go b/services/core/internal/store/hosted_initialization_failure_public_test.go similarity index 97% rename from services/agents-api/internal/store/hosted_initialization_failure_public_test.go rename to services/core/internal/store/hosted_initialization_failure_public_test.go index bb5590ec1..cec555980 100644 --- a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go +++ b/services/core/internal/store/hosted_initialization_failure_public_test.go @@ -16,14 +16,14 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/initial_files.go b/services/core/internal/store/initial_files.go new file mode 100644 index 000000000..29f3ceda7 --- /dev/null +++ b/services/core/internal/store/initial_files.go @@ -0,0 +1,237 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "io" + "path" + "strings" + "unicode/utf8" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +const MaxInitialFileBytes = 50 << 20 + +// InitialFile keeps confidential input separate from ordinary Session configuration. +type InitialFile struct { + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + Data []byte `json:"data,omitempty"` +} + +type InitialFileMetadata struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Path string `json:"path"` + FileID string `json:"file_id,omitempty"` + SizeBytes *int64 `json:"size_bytes,omitempty"` +} + +func ValidateInitialFiles(files []InitialFile) error { + if len(files) > 50 { + return ErrInvalidInput + } + total := 0 + seen := map[string]bool{} + for _, f := range files { + if !utf8.ValidString(f.Path) || strings.ContainsAny(f.Path, "\\\x00\r\n") || len(f.Path) > 4096 || !strings.HasPrefix(f.Path, "/workspace/") || path.Clean(f.Path) != f.Path || seen[f.Path] { + return ErrInvalidInput + } + seen[f.Path] = true + switch f.Type { + case "inline": + if f.FileID != "" || len(f.Data) > 5<<20 { + return ErrInvalidInput + } + total += len(f.Data) + case "file_id": + if f.FileID == "" || len(f.Data) != 0 { + return ErrInvalidInput + } + default: + return ErrInvalidInput + } + } + if total > 10<<20 { + return ErrInvalidInput + } + return nil +} + +func initialFileMetadata(files []InitialFile) []InitialFileMetadata { + result := make([]InitialFileMetadata, 0, len(files)) + for _, f := range files { + m := InitialFileMetadata{Type: f.Type, Path: f.Path, FileID: f.FileID} + if f.Type == "inline" { + size := int64(len(f.Data)) + m.SizeBytes = &size + } + result = append(result, m) + } + return result +} + +func (s *Store) sealTemplateFiles(tenant, id string, files []InitialFile) ([]byte, []byte, error) { + if err := ValidateInitialFiles(files); err != nil { + return nil, nil, err + } + metadata, err := json.Marshal(initialFileMetadata(files)) + if err != nil { + return nil, nil, err + } + if len(files) == 0 { + return metadata, nil, nil + } + input, err := json.Marshal(files) + if err != nil { + return nil, nil, err + } + encrypted, err := s.credentialCipher.SealEnvironmentFile(input, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "environment_template", OwnerID: id, FileID: "files"}) + return metadata, encrypted, err +} + +// ResolveEnvironmentTemplate reads one atomic snapshot; public reads need no decryption key. +func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { + return s.resolveEnvironmentTemplate(ctx, s.queries, tenant, id) +} + +func (s *Store) resolveEnvironmentTemplate(ctx context.Context, q *sqlc.Queries, tenant, id string) (EnvironmentTemplate, []InitialFile, error) { + lookup, err := deviceLookup(tenant, id) + if err != nil { + return EnvironmentTemplate{}, nil, ErrNotFound + } + row, err := q.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills, Plugins: row.Plugins, CapabilityDirectories: row.CapabilityDirectories}, err) + if err != nil { + return value, nil, err + } + value.Initialization.Packages = value.Packages + canonicalTenant := uuid.UUID(lookup.TenantID.Bytes).String() + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "env", row.EnvContents, &value.Initialization.Env); err != nil { + return value, nil, err + } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { + return value, nil, err + } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "skills", row.SkillContents, &value.Initialization.Skills); err != nil { + return value, nil, err + } + if len(value.Skills) != len(value.Initialization.Skills) { + return value, nil, ErrInvalidInput + } + for i, metadata := range value.Skills { + if metadata != value.Initialization.Skills[i].Metadata { + return value, nil, ErrInvalidInput + } + } + value.Initialization.CapabilityDirectories = append([]string(nil), value.CapabilityDirectories...) + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "plugins", row.PluginContents, &value.Initialization.Plugins); err != nil { + return value, nil, err + } + if len(value.Plugins) != len(value.Initialization.Plugins) { + return value, nil, ErrInvalidInput + } + for i, metadata := range value.Plugins { + if metadata != value.Initialization.Plugins[i].Metadata { + return value, nil, ErrInvalidInput + } + } + if err = value.Initialization.Validate(); err != nil { + return value, nil, err + } + if len(row.FileContents) == 0 { + if len(value.Files) > 0 { + return value, nil, ErrInvalidInput + } + return value, nil, nil + } + plain, err := s.credentialCipher.OpenEnvironmentFile(row.FileContents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "environment_template", OwnerID: value.ID, FileID: "files"}) + if err != nil { + return value, nil, err + } + var files []InitialFile + if json.Unmarshal(plain, &files) != nil || ValidateInitialFiles(files) != nil { + return value, nil, ErrInvalidInput + } + return value, files, nil +} + +func (s *Store) saveInitialFiles(ctx context.Context, q *sqlc.Queries, tx pgx.Tx, tenant string, session pgtype.UUID, files []InitialFile) ([]byte, error) { + if err := ValidateInitialFiles(files); err != nil { + return nil, err + } + tenantID, err := parseID(tenant) + if err != nil { + return nil, err + } + tenant = uuid.UUID(tenantID.Bytes).String() + metadata := initialFileMetadata(files) + for i, f := range files { + body := f.Data + if f.Type == "file_id" { + sourceTenant, sourceID, err := sourceFileIDs(tenant, f.FileID) + if err != nil { + return nil, err + } + source, err := q.LockInitialSourceFile(ctx, sqlc.LockInitialSourceFileParams{TenantID: sourceTenant, ID: sourceID}) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + err = consumeSourceFile(ctx, tx, source, func(source SourceFile, reader io.Reader) error { + if source.SizeBytes > MaxInitialFileBytes { + return ErrInvalidInput + } + var err error + body, err = io.ReadAll(io.LimitReader(reader, MaxInitialFileBytes+1)) + if err == nil && (len(body) > MaxInitialFileBytes || int64(len(body)) != source.SizeBytes) { + return ErrInvalidInput + } + return err + }) + if err != nil { + return nil, err + } + } + id := uuid.NewString() + size := int64(len(body)) + metadata[i].ID = id + metadata[i].SizeBytes = &size + encrypted, err := s.credentialCipher.SealEnvironmentFile(body, credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "session", OwnerID: uuid.UUID(session.Bytes).String(), FileID: id}) + if err != nil { + return nil, err + } + fileID, _ := parseID(id) + if err := q.CreateInitialEnvironmentFile(ctx, sqlc.CreateInitialEnvironmentFileParams{ID: fileID, SessionID: session, Position: int32(i), Path: f.Path, SizeBytes: size, Contents: encrypted}); err != nil { + return nil, err + } + } + return json.Marshal(metadata) +} + +// ReadInitialEnvironmentFile decrypts only the next frozen file, bounding memory per installation. +func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (InitialFileMetadata, []byte, error) { + lookup, err := deviceLookup(tenant, session) + if err != nil { + return InitialFileMetadata{}, nil, err + } + row, err := s.queries.GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, SessionID: lookup.ID, Position: int32(position)}) + if err != nil { + return InitialFileMetadata{}, nil, err + } + id := uuid.UUID(row.ID.Bytes).String() + body, err := s.credentialCipher.OpenEnvironmentFile(row.Contents, credentialcrypto.EnvironmentFileBinding{TenantID: uuid.UUID(lookup.TenantID.Bytes).String(), Resource: "session", OwnerID: uuid.UUID(lookup.ID.Bytes).String(), FileID: id}) + if err == nil && int64(len(body)) != row.SizeBytes { + err = ErrInvalidInput + } + return InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, err +} diff --git a/services/agents-api/internal/store/initial_files_http_test.go b/services/core/internal/store/initial_files_http_test.go similarity index 89% rename from services/agents-api/internal/store/initial_files_http_test.go rename to services/core/internal/store/initial_files_http_test.go index 30f5c1601..4fc2ebd11 100644 --- a/services/agents-api/internal/store/initial_files_http_test.go +++ b/services/core/internal/store/initial_files_http_test.go @@ -8,10 +8,10 @@ import ( "net/http/httptest" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/initial_files_test.go b/services/core/internal/store/initial_files_test.go similarity index 98% rename from services/agents-api/internal/store/initial_files_test.go rename to services/core/internal/store/initial_files_test.go index 3fb2b870e..f7f8bfcc9 100644 --- a/services/agents-api/internal/store/initial_files_test.go +++ b/services/core/internal/store/initial_files_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/input_batches_test.go b/services/core/internal/store/input_batches_test.go similarity index 100% rename from services/agents-api/internal/store/input_batches_test.go rename to services/core/internal/store/input_batches_test.go diff --git a/services/agents-api/internal/store/input_conflicts_public_test.go b/services/core/internal/store/input_conflicts_public_test.go similarity index 98% rename from services/agents-api/internal/store/input_conflicts_public_test.go rename to services/core/internal/store/input_conflicts_public_test.go index cea6a5b82..e29375520 100644 --- a/services/agents-api/internal/store/input_conflicts_public_test.go +++ b/services/core/internal/store/input_conflicts_public_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/item_events.go b/services/core/internal/store/item_events.go similarity index 95% rename from services/agents-api/internal/store/item_events.go rename to services/core/internal/store/item_events.go index ff2cb17fb..8345935d7 100644 --- a/services/agents-api/internal/store/item_events.go +++ b/services/core/internal/store/item_events.go @@ -4,8 +4,8 @@ import ( "context" "reflect" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/item_order_migration_test.go b/services/core/internal/store/item_order_migration_test.go similarity index 97% rename from services/agents-api/internal/store/item_order_migration_test.go rename to services/core/internal/store/item_order_migration_test.go index acce156f9..d700c4e43 100644 --- a/services/agents-api/internal/store/item_order_migration_test.go +++ b/services/core/internal/store/item_order_migration_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/item_order_test.go b/services/core/internal/store/item_order_test.go similarity index 97% rename from services/agents-api/internal/store/item_order_test.go rename to services/core/internal/store/item_order_test.go index 2a9f9e9e9..d6f04bd82 100644 --- a/services/agents-api/internal/store/item_order_test.go +++ b/services/core/internal/store/item_order_test.go @@ -7,8 +7,8 @@ import ( "sort" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/item_projection.go b/services/core/internal/store/item_projection.go similarity index 94% rename from services/agents-api/internal/store/item_projection.go rename to services/core/internal/store/item_projection.go index a366b92ca..db5bdea11 100644 --- a/services/agents-api/internal/store/item_projection.go +++ b/services/core/internal/store/item_projection.go @@ -6,9 +6,9 @@ import ( "errors" "fmt" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/item_reads.go b/services/core/internal/store/item_reads.go similarity index 91% rename from services/agents-api/internal/store/item_reads.go rename to services/core/internal/store/item_reads.go index 52e972fc7..2eee5cf67 100644 --- a/services/agents-api/internal/store/item_reads.go +++ b/services/core/internal/store/item_reads.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/item_reads_test.go b/services/core/internal/store/item_reads_test.go similarity index 98% rename from services/agents-api/internal/store/item_reads_test.go rename to services/core/internal/store/item_reads_test.go index c55a80c8a..8d546f42d 100644 --- a/services/agents-api/internal/store/item_reads_test.go +++ b/services/core/internal/store/item_reads_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/item_serialization_test.go b/services/core/internal/store/item_serialization_test.go similarity index 100% rename from services/agents-api/internal/store/item_serialization_test.go rename to services/core/internal/store/item_serialization_test.go diff --git a/services/agents-api/internal/store/json_object.go b/services/core/internal/store/json_object.go similarity index 100% rename from services/agents-api/internal/store/json_object.go rename to services/core/internal/store/json_object.go diff --git a/services/agents-api/internal/store/list_cursor_public_test.go b/services/core/internal/store/list_cursor_public_test.go similarity index 98% rename from services/agents-api/internal/store/list_cursor_public_test.go rename to services/core/internal/store/list_cursor_public_test.go index fa38b3a91..4f763cdc4 100644 --- a/services/agents-api/internal/store/list_cursor_public_test.go +++ b/services/core/internal/store/list_cursor_public_test.go @@ -11,12 +11,12 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/list_cursors.go b/services/core/internal/store/list_cursors.go similarity index 96% rename from services/agents-api/internal/store/list_cursors.go rename to services/core/internal/store/list_cursors.go index e20f93853..6aad72f19 100644 --- a/services/agents-api/internal/store/list_cursors.go +++ b/services/core/internal/store/list_cursors.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/echotext" ) // InvalidCursorError reports a list `after` cursor that does not name a diff --git a/services/agents-api/internal/store/list_query_public_test.go b/services/core/internal/store/list_query_public_test.go similarity index 84% rename from services/agents-api/internal/store/list_query_public_test.go rename to services/core/internal/store/list_query_public_test.go index f2c1f7df1..6dad9e663 100644 --- a/services/agents-api/internal/store/list_query_public_test.go +++ b/services/core/internal/store/list_query_public_test.go @@ -9,11 +9,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/local_artifact_export_test.go b/services/core/internal/store/local_artifact_export_test.go similarity index 95% rename from services/agents-api/internal/store/local_artifact_export_test.go rename to services/core/internal/store/local_artifact_export_test.go index f259d9643..115efbe6c 100644 --- a/services/agents-api/internal/store/local_artifact_export_test.go +++ b/services/core/internal/store/local_artifact_export_test.go @@ -6,9 +6,9 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func completeLocalArtifactExport(t *testing.T, h *dispatchHarness, worker *execution.Worker, environment store.Environment) { diff --git a/services/agents-api/internal/store/local_environment_devices.go b/services/core/internal/store/local_environment_devices.go similarity index 96% rename from services/agents-api/internal/store/local_environment_devices.go rename to services/core/internal/store/local_environment_devices.go index bdeac5cf3..9ed2e63c8 100644 --- a/services/agents-api/internal/store/local_environment_devices.go +++ b/services/core/internal/store/local_environment_devices.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/local_environment_devices_test.go b/services/core/internal/store/local_environment_devices_test.go similarity index 98% rename from services/agents-api/internal/store/local_environment_devices_test.go rename to services/core/internal/store/local_environment_devices_test.go index 38e6f420c..93d42d4b7 100644 --- a/services/agents-api/internal/store/local_environment_devices_test.go +++ b/services/core/internal/store/local_environment_devices_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/local_environment_file_write_test.go b/services/core/internal/store/local_environment_file_write_test.go similarity index 96% rename from services/agents-api/internal/store/local_environment_file_write_test.go rename to services/core/internal/store/local_environment_file_write_test.go index a852c8227..9622519ae 100644 --- a/services/agents-api/internal/store/local_environment_file_write_test.go +++ b/services/core/internal/store/local_environment_file_write_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/local_environment_worker_test.go b/services/core/internal/store/local_environment_worker_test.go similarity index 96% rename from services/agents-api/internal/store/local_environment_worker_test.go rename to services/core/internal/store/local_environment_worker_test.go index 5b8f7642d..0517f7194 100644 --- a/services/agents-api/internal/store/local_environment_worker_test.go +++ b/services/core/internal/store/local_environment_worker_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/managed_test_store_test.go b/services/core/internal/store/managed_test_store_test.go similarity index 100% rename from services/agents-api/internal/store/managed_test_store_test.go rename to services/core/internal/store/managed_test_store_test.go diff --git a/services/agents-api/internal/store/mcode_public_native_test.go b/services/core/internal/store/mcode_public_native_test.go similarity index 95% rename from services/agents-api/internal/store/mcode_public_native_test.go rename to services/core/internal/store/mcode_public_native_test.go index ac27f68e5..f718522c3 100644 --- a/services/agents-api/internal/store/mcode_public_native_test.go +++ b/services/core/internal/store/mcode_public_native_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/mcp_credential_selection_public_test.go b/services/core/internal/store/mcp_credential_selection_public_test.go similarity index 98% rename from services/agents-api/internal/store/mcp_credential_selection_public_test.go rename to services/core/internal/store/mcp_credential_selection_public_test.go index cf755bb16..95adbbe9e 100644 --- a/services/agents-api/internal/store/mcp_credential_selection_public_test.go +++ b/services/core/internal/store/mcp_credential_selection_public_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/mcp_credentials.go b/services/core/internal/store/mcp_credentials.go new file mode 100644 index 000000000..f2209e14b --- /dev/null +++ b/services/core/internal/store/mcp_credentials.go @@ -0,0 +1,196 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/echotext" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type MCPCredentialRequest struct { + ServerLabel, ServerURL string + CredentialID *string +} + +// MCPCredentialBinding freezes a non-secret selection, including anonymous +// servers. It is private execution configuration, not the public MCP tool shape. +type MCPCredentialBinding struct { + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url"` + VaultID string `json:"vault_id,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + AuthType string `json:"auth_type,omitempty"` +} + +// MCPCredentialSelectionError rejects a Session MCP credential selection with +// the observed official message (MV-03); the API reports a Conflict as 409 +// conflict_error and any other as 400 invalid_request_error. Selection searches +// only the attached Vaults, which the caller owns, so a missing, foreign-tenant, +// unattached or malformed reference produces the same error, and only a +// credential of an attached Vault can report a server_url mismatch. +type MCPCredentialSelectionError struct { + Conflict bool + Message string +} + +func (e *MCPCredentialSelectionError) Error() string { return e.Message } + +// echoed repeats a caller-supplied value in a selection message only within +// the shared bound; otherwise the message leaves it out. +func echoed(value string) string { + if !echotext.Allowed(value) { + return "" + } + return " " + value +} + +func mcpCredentialRequiresVault() error { + return &MCPCredentialSelectionError{Message: "MCP credential_id requires an attached vault"} +} + +func mcpCredentialNotAttached(id string) error { + return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " was not found in an attached vault"} +} + +func mcpCredentialURLMismatch(id, url string) error { + return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " does not match server_url" + echoed(url)} +} + +func mcpCredentialAmbiguous(url string) error { + return &MCPCredentialSelectionError{Conflict: true, Message: "multiple attached vault credentials match MCP server_url" + echoed(url) + "; specify credential_id"} +} + +func attachedVaultIDs(ids []string) ([]pgtype.UUID, error) { + result := make([]pgtype.UUID, 0, len(ids)) + seen := map[pgtype.UUID]bool{} + for _, raw := range ids { + id, err := parseID(raw) + if err != nil { + return nil, ErrNotFound + } + if !seen[id] { + result = append(result, id) + seen[id] = true + } + } + return result, nil +} + +// ResolveMCPCredentials reads metadata only. Resource changes after this read do +// not reselect credentials for an accepted Session or its creation retries. +func (s *Store) ResolveMCPCredentials(ctx context.Context, tenantID string, vaultIDs []string, requests []MCPCredentialRequest) ([]MCPCredentialBinding, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + vaults, err := attachedVaultIDs(vaultIDs) + if err != nil { + return nil, err + } + owned, err := s.queries.GetAttachedVaultIDs(ctx, sqlc.GetAttachedVaultIDsParams{TenantID: tenant, VaultIds: vaults}) + if err != nil { + return nil, errors.New("cannot resolve attached Vaults") + } + if len(owned) != len(vaults) { + return nil, ErrNotFound + } + bindings := make([]MCPCredentialBinding, 0, len(requests)) + for _, request := range requests { + if request.ServerLabel == "" || request.ServerURL == "" { + return nil, ErrInvalidInput + } + var id pgtype.UUID + if request.CredentialID != nil { + if len(vaults) == 0 { + return nil, mcpCredentialRequiresVault() + } + id, err = parseID(*request.CredentialID) + if err != nil { + return nil, mcpCredentialNotAttached(*request.CredentialID) + } + } + rows, err := s.queries.FindMCPCredentials(ctx, sqlc.FindMCPCredentialsParams{ + TenantID: tenant, VaultIds: vaults, McpServerUrl: request.ServerURL, CredentialID: id, + }) + if err != nil { + return nil, errors.New("cannot resolve MCP credential") + } + if request.CredentialID != nil { + if len(rows) == 0 { + return nil, mcpCredentialNotAttached(*request.CredentialID) + } + if rows[0].McpServerUrl != request.ServerURL { + return nil, mcpCredentialURLMismatch(*request.CredentialID, request.ServerURL) + } + } + if len(rows) > 1 { + return nil, mcpCredentialAmbiguous(request.ServerURL) + } + binding := MCPCredentialBinding{ServerLabel: request.ServerLabel, ServerURL: request.ServerURL} + if len(rows) == 1 { + binding.VaultID, binding.CredentialID = uuid.UUID(rows[0].VaultID.Bytes).String(), uuid.UUID(rows[0].ID.Bytes).String() + binding.AuthType = rows[0].AuthType + } + bindings = append(bindings, binding) + } + return bindings, nil +} + +// MCPBearerToken is execution-only: recheck the complete frozen authorization +// before decrypting. Never persist or log its result, or downgrade failure to an +// anonymous request. Public resource queries do not select ciphertext. +func (s *Store) MCPBearerToken(ctx context.Context, tenantID string, vaultIDs []string, binding MCPCredentialBinding) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", ErrNotFound + } + vaults, err := attachedVaultIDs(vaultIDs) + if err != nil { + return "", err + } + vault, err := parseID(binding.VaultID) + if err != nil { + return "", ErrNotFound + } + id, err := parseID(binding.CredentialID) + if err != nil || (binding.AuthType != "static_bearer" && binding.AuthType != "mcp_oauth") || binding.ServerURL == "" { + return "", ErrNotFound + } + if binding.AuthType == "mcp_oauth" { + attached := false + for _, candidate := range vaults { + if candidate == vault { + attached = true + } + } + if !attached { + return "", ErrNotFound + } + return s.oauthBearerToken(ctx, tenantID, binding) + } + ciphertext, err := s.queries.GetMCPStaticCredentialCiphertext(ctx, sqlc.GetMCPStaticCredentialCiphertextParams{ + TenantID: tenant, VaultIds: vaults, VaultID: vault, CredentialID: id, McpServerUrl: binding.ServerURL, + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", errors.New("cannot read MCP credential") + } + if s.credentialCipher == nil { + return "", ErrCredentialStorageUnavailable + } + plaintext, err := s.credentialCipher.Open(ciphertext, credentialcrypto.Binding{ + TenantID: uuid.UUID(tenant.Bytes).String(), VaultID: uuid.UUID(vault.Bytes).String(), CredentialID: uuid.UUID(id.Bytes).String(), + AuthType: binding.AuthType, Destination: binding.ServerURL, + }) + if err != nil { + return "", errors.New("MCP credential decryption failed") + } + return string(plaintext), nil +} diff --git a/services/agents-api/internal/store/mcp_credentials_oauth.go b/services/core/internal/store/mcp_credentials_oauth.go similarity index 96% rename from services/agents-api/internal/store/mcp_credentials_oauth.go rename to services/core/internal/store/mcp_credentials_oauth.go index 3d8b9a9c2..ac55182b7 100644 --- a/services/agents-api/internal/store/mcp_credentials_oauth.go +++ b/services/core/internal/store/mcp_credentials_oauth.go @@ -5,7 +5,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" ) const oauthRefreshTimeout = 20 * time.Second diff --git a/services/agents-api/internal/store/mcp_credentials_oauth_test.go b/services/core/internal/store/mcp_credentials_oauth_test.go similarity index 99% rename from services/agents-api/internal/store/mcp_credentials_oauth_test.go rename to services/core/internal/store/mcp_credentials_oauth_test.go index cc0c3b2b4..1d176013e 100644 --- a/services/agents-api/internal/store/mcp_credentials_oauth_test.go +++ b/services/core/internal/store/mcp_credentials_oauth_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/google/uuid" ) diff --git a/services/core/internal/store/mcp_credentials_test.go b/services/core/internal/store/mcp_credentials_test.go new file mode 100644 index 000000000..f7779139b --- /dev/null +++ b/services/core/internal/store/mcp_credentials_test.go @@ -0,0 +1,138 @@ +package store + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var vaults []Vault + for _, owner := range []string{tenant, tenant, foreign} { + vault, err := s.CreateVault(t.Context(), owner, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + vaults = append(vaults, vault) + } + token := " \t" + uuid.NewString() + "雪\n" + destination := "https://mcp.example/tools" + create := func(vault Vault) Credential { + t.Helper() + value, err := s.CreateStaticCredential(t.Context(), vault.TenantID, vault.ID, CreateStaticCredentialInput{Name: "private", MCPServerURL: destination, Token: token}) + if err != nil { + t.Fatal(err) + } + return value + } + first, foreignCredential := create(vaults[0]), create(vaults[2]) + attached := []string{vaults[0].ID, vaults[1].ID} + requests := []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination}, {ServerLabel: "anonymous", ServerURL: "https://anonymous.example/mcp"}} + bindings, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) + if err != nil || len(bindings) != 2 || bindings[0].CredentialID != first.ID || bindings[0].AuthType != "static_bearer" || bindings[1].CredentialID != "" { + t.Fatal("metadata selection or frozen anonymous decision differs", err) + } + encoded, err := json.Marshal(bindings) + if err != nil || bytes.Contains(encoded, []byte(token)) || strings.Contains(string(encoded), "ciphertext") { + t.Fatal("private binding contains secret material") + } + second := create(vaults[1]) + if _, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+destination+"; specify credential_id") { + t.Fatal("ambiguous selection was admitted", err) + } + requests[0].CredentialID = &second.ID + explicit, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) + if err != nil || explicit[0].CredentialID != second.ID || requests[1].CredentialID != nil { + t.Fatal("explicit selection did not disambiguate", err) + } + notAttached := func(id string) string { return "MCP credential_id " + id + " was not found in an attached vault" } + for _, tc := range []struct { + owner string + vaults []string + id, url string + message string // Empty for the unchanged Vault 404. + }{ + {tenant, attached, foreignCredential.ID, destination, notAttached(foreignCredential.ID)}, + {tenant, []string{vaults[1].ID}, first.ID, destination, notAttached(first.ID)}, + {tenant, attached, "not-a-credential", destination, notAttached("not-a-credential")}, + {tenant, attached, first.ID, destination + "/other", "MCP credential_id " + first.ID + " does not match server_url " + destination + "/other"}, + {foreign, attached, first.ID, destination, ""}, + {tenant, []string{vaults[0].ID, vaults[2].ID}, first.ID, destination, ""}, + {tenant, []string{uuid.NewString()}, first.ID, destination, ""}, + } { + _, err := public.ResolveMCPCredentials(t.Context(), tc.owner, tc.vaults, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) + if tc.message == "" && !errors.Is(err, ErrNotFound) || tc.message != "" && !isSelectionError(err, false, tc.message) { + t.Fatal("unowned, unattached or wrong-destination selection was admitted", err) + } + } + if _, err := public.ResolveMCPCredentials(t.Context(), tenant, nil, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination, CredentialID: &first.ID}}); !isSelectionError(err, false, "MCP credential_id requires an attached vault") { + t.Fatal("a reference without attachments was admitted", err) + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]) + if err != nil || got != token { + t.Fatal("frozen selection or opaque bytes changed across restart", err) + } + if got, err := public.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); !errors.Is(err, ErrCredentialStorageUnavailable) || got != "" { + t.Fatal("missing key did not fail execution closed") + } + key[0] ^= 1 + wrong, _ := credentialcrypto.New(key) + if got, err := NewWithCredentialCipher(pool, wrong).MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" || strings.Contains(err.Error(), token) { + t.Fatal("wrong key leaked or decrypted a credential") + } + for _, mutate := range []func(*MCPCredentialBinding){ + func(b *MCPCredentialBinding) { b.VaultID = vaults[1].ID }, + func(b *MCPCredentialBinding) { b.CredentialID = foreignCredential.ID }, + func(b *MCPCredentialBinding) { b.ServerURL += "/other" }, + func(b *MCPCredentialBinding) { b.AuthType = "other" }, + } { + binding := bindings[0] + mutate(&binding) + if got, err := s.MCPBearerToken(t.Context(), tenant, attached, binding); !errors.Is(err, ErrNotFound) || got != "" { + t.Fatal("substituted frozen authorization was decrypted") + } + } + for _, scope := range []struct { + owner string + vaults []string + }{{foreign, attached}, {tenant, []string{vaults[1].ID}}} { + if got, err := s.MCPBearerToken(t.Context(), scope.owner, scope.vaults, bindings[0]); !errors.Is(err, ErrNotFound) || got != "" { + t.Fatal("tenant or attachment authorization was bypassed") + } + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext, 15, get_byte(token_ciphertext,15) # 1) WHERE id=$1", first.ID); err != nil { + t.Fatal(err) + } + if got, err := s.MCPBearerToken(t.Context(), tenant, attached, bindings[0]); err == nil || got != "" { + t.Fatal("tampered ciphertext decrypted") + } + if _, err := public.GetCredential(t.Context(), tenant, first.VaultID, first.ID); err != nil { + t.Fatal("safe metadata lookup depended on ciphertext", err) + } +} + +func isSelectionError(err error, conflict bool, message string) bool { + var selection *MCPCredentialSelectionError + return errors.As(err, &selection) && selection.Conflict == conflict && selection.Message == message +} diff --git a/services/agents-api/internal/store/message_image_admission_test.go b/services/core/internal/store/message_image_admission_test.go similarity index 91% rename from services/agents-api/internal/store/message_image_admission_test.go rename to services/core/internal/store/message_image_admission_test.go index bb2af240b..275753455 100644 --- a/services/agents-api/internal/store/message_image_admission_test.go +++ b/services/core/internal/store/message_image_admission_test.go @@ -6,9 +6,9 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func imageAdmissionBatch() []store.Input { diff --git a/services/agents-api/internal/store/message_images_native_test.go b/services/core/internal/store/message_images_native_test.go similarity index 94% rename from services/agents-api/internal/store/message_images_native_test.go rename to services/core/internal/store/message_images_native_test.go index 082a9d62e..a80806f47 100644 --- a/services/agents-api/internal/store/message_images_native_test.go +++ b/services/core/internal/store/message_images_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/message_input_helpers_test.go b/services/core/internal/store/message_input_helpers_test.go similarity index 75% rename from services/agents-api/internal/store/message_input_helpers_test.go rename to services/core/internal/store/message_input_helpers_test.go index 4016ca1c7..e4007b401 100644 --- a/services/agents-api/internal/store/message_input_helpers_test.go +++ b/services/core/internal/store/message_input_helpers_test.go @@ -1,7 +1,7 @@ package store_test import ( - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "testing" ) diff --git a/services/agents-api/internal/store/model_protocol_native_test.go b/services/core/internal/store/model_protocol_native_test.go similarity index 94% rename from services/agents-api/internal/store/model_protocol_native_test.go rename to services/core/internal/store/model_protocol_native_test.go index 51211a07b..3b01021be 100644 --- a/services/agents-api/internal/store/model_protocol_native_test.go +++ b/services/core/internal/store/model_protocol_native_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/model_provider_fixture_test.go b/services/core/internal/store/model_provider_fixture_test.go new file mode 100644 index 000000000..4abc35e41 --- /dev/null +++ b/services/core/internal/store/model_provider_fixture_test.go @@ -0,0 +1,28 @@ +package store_test + +import ( + "context" + "encoding/json" + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// Hosted and self-hosted Sessions must freeze a model provider. HTTP fixtures +// supply one here instead of relaxing that check; the store needs a credential +// key (store.NewModelTestStore). + +// fixtureDeploymentProvider configures a deployment default for every harness. +func fixtureDeploymentProvider() api.Option { + return api.WithModelProviderDefaults(func(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { + return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: store.FixtureModelProvider(harness), Revision: uuid.New()}, nil + }) +} + +// fixtureSessionProvider is the top-level Session request member that supplies +// the harness's fixture bundle, for self-hosted Sessions. +func fixtureSessionProvider(harness string) string { + raw, _ := json.Marshal(map[string]any{"model_provider": store.FixtureModelProvider(harness)}) + return `"x_agents_core":` + string(raw) +} diff --git a/services/agents-api/internal/store/native_daemon_test.go b/services/core/internal/store/native_daemon_test.go similarity index 100% rename from services/agents-api/internal/store/native_daemon_test.go rename to services/core/internal/store/native_daemon_test.go diff --git a/services/agents-api/internal/store/native_environment_test.go b/services/core/internal/store/native_environment_test.go similarity index 98% rename from services/agents-api/internal/store/native_environment_test.go rename to services/core/internal/store/native_environment_test.go index 6708290eb..81582586c 100644 --- a/services/agents-api/internal/store/native_environment_test.go +++ b/services/core/internal/store/native_environment_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestNativeNoExecutionEnvironment(t *testing.T) { diff --git a/services/agents-api/internal/store/native_public_execution_test.go b/services/core/internal/store/native_public_execution_test.go similarity index 89% rename from services/agents-api/internal/store/native_public_execution_test.go rename to services/core/internal/store/native_public_execution_test.go index 7f999ecce..2c80e7033 100644 --- a/services/agents-api/internal/store/native_public_execution_test.go +++ b/services/core/internal/store/native_public_execution_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/native_recovery_test.go b/services/core/internal/store/native_recovery_test.go similarity index 100% rename from services/agents-api/internal/store/native_recovery_test.go rename to services/core/internal/store/native_recovery_test.go diff --git a/services/agents-api/internal/store/no_environment_test.go b/services/core/internal/store/no_environment_test.go similarity index 92% rename from services/agents-api/internal/store/no_environment_test.go rename to services/core/internal/store/no_environment_test.go index 7417db345..cf26b73f3 100644 --- a/services/agents-api/internal/store/no_environment_test.go +++ b/services/core/internal/store/no_environment_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestNoEnvironmentRejectsUnadvertisedDeviceBeforeClaim(t *testing.T) { diff --git a/services/agents-api/internal/store/node_host_history.go b/services/core/internal/store/node_host_history.go similarity index 97% rename from services/agents-api/internal/store/node_host_history.go rename to services/core/internal/store/node_host_history.go index 048c37aa4..5802c402c 100644 --- a/services/agents-api/internal/store/node_host_history.go +++ b/services/core/internal/store/node_host_history.go @@ -6,8 +6,8 @@ import ( "math" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/node_host_history_test.go b/services/core/internal/store/node_host_history_test.go similarity index 100% rename from services/agents-api/internal/store/node_host_history_test.go rename to services/core/internal/store/node_host_history_test.go diff --git a/services/agents-api/internal/store/oac_runtime_names_migration_test.go b/services/core/internal/store/oac_runtime_names_migration_test.go similarity index 100% rename from services/agents-api/internal/store/oac_runtime_names_migration_test.go rename to services/core/internal/store/oac_runtime_names_migration_test.go diff --git a/services/agents-api/internal/store/path_id_semantics_public_test.go b/services/core/internal/store/path_id_semantics_public_test.go similarity index 98% rename from services/agents-api/internal/store/path_id_semantics_public_test.go rename to services/core/internal/store/path_id_semantics_public_test.go index 305463d82..44d44da4b 100644 --- a/services/agents-api/internal/store/path_id_semantics_public_test.go +++ b/services/core/internal/store/path_id_semantics_public_test.go @@ -10,10 +10,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/prepared_dispatch_failure_test.go b/services/core/internal/store/prepared_dispatch_failure_test.go similarity index 97% rename from services/agents-api/internal/store/prepared_dispatch_failure_test.go rename to services/core/internal/store/prepared_dispatch_failure_test.go index 47d579096..393f66602 100644 --- a/services/agents-api/internal/store/prepared_dispatch_failure_test.go +++ b/services/core/internal/store/prepared_dispatch_failure_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { diff --git a/services/agents-api/internal/store/prepared_dispatch_test.go b/services/core/internal/store/prepared_dispatch_test.go similarity index 97% rename from services/agents-api/internal/store/prepared_dispatch_test.go rename to services/core/internal/store/prepared_dispatch_test.go index 5d611abe8..7b9efaca5 100644 --- a/services/agents-api/internal/store/prepared_dispatch_test.go +++ b/services/core/internal/store/prepared_dispatch_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/project_api_keys.go b/services/core/internal/store/project_api_keys.go new file mode 100644 index 000000000..c9ef26654 --- /dev/null +++ b/services/core/internal/store/project_api_keys.go @@ -0,0 +1,197 @@ +package store + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var ErrProjectAPIKeyExists = errors.New("project API key ID already exists") + +type ProjectAPIKey struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at"` +} +type IssuedProjectAPIKey struct { + ProjectAPIKey + Key string `json:"key"` +} +type ProjectAPIKeyBinding struct { + Key ProjectAPIKey + Principal identity.Principal +} +type ProjectAPIKeyPage struct { + Data []ProjectAPIKey `json:"data"` + HasMore bool `json:"has_more"` +} + +func validProjectKeyDigest(digest string) bool { + decoded, err := hex.DecodeString(digest) + return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == digest +} +func projectKeyMetadata(row sqlc.ProjectApiKey) ProjectAPIKey { + key := ProjectAPIKey{ID: uuid.UUID(row.ID.Bytes).String(), ProjectID: uuid.UUID(row.ProjectID.Bytes).String(), Name: row.Name, Prefix: row.Prefix, CreatedAt: row.CreatedAt.Time} + if row.RevokedAt.Valid { + v := row.RevokedAt.Time + key.RevokedAt = &v + } + return key +} +func newProjectSecret() (string, string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + token := "pc_" + base64.RawURLEncoding.EncodeToString(raw) + digest := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(digest[:]), nil +} +func (s *Store) CreateProjectAPIKey(ctx context.Context, project, id, name string) (IssuedProjectAPIKey, error) { + projectID, err := parseID(project) + if err != nil { + return IssuedProjectAPIKey{}, ErrNotFound + } + keyID, err := parseID(id) + if err != nil { + return IssuedProjectAPIKey{}, err + } + name, err = adminResourceName(name, 80) + if err != nil { + return IssuedProjectAPIKey{}, err + } + token, digest, err := newProjectSecret() + if err != nil { + return IssuedProjectAPIKey{}, err + } + var result IssuedProjectAPIKey + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + p, err := q.LockProject(ctx, projectID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if p.ArchivedAt.Valid { + return ErrProjectArchived + } + row, err := q.CreateProjectAPIKey(ctx, sqlc.CreateProjectAPIKeyParams{ID: keyID, Name: name, Prefix: token[:11], TokenSha256: digest, ProjectID: projectID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrProjectAPIKeyExists + } + if err != nil { + return err + } + result = IssuedProjectAPIKey{ProjectAPIKey: projectKeyMetadata(row), Key: token} + return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "create", "api_key", id) + }) + if err != nil { + return IssuedProjectAPIKey{}, err + } + return result, nil +} +func (s *Store) ListProjectAPIKeys(ctx context.Context, project, after string, limit int, ascending bool) (ProjectAPIKeyPage, error) { + result := ProjectAPIKeyPage{Data: []ProjectAPIKey{}} + if limit < 1 || limit > 100 { + return result, ErrInvalidInput + } + p, err := s.GetProject(ctx, project) + if err != nil { + return result, err + } + projectID, _ := parseID(p.Project.ID) + if after != "" { + id, err := parseID(after) + if err != nil { + return result, ErrNotFound + } + if _, err := s.queries.GetProjectAPIKeyForProject(ctx, sqlc.GetProjectAPIKeyForProjectParams{ID: id, ProjectID: projectID}); errors.Is(err, pgx.ErrNoRows) { + return result, ErrNotFound + } else if err != nil { + return result, err + } + } + rows, err := s.queries.ListProjectAPIKeys(ctx, sqlc.ListProjectAPIKeysParams{ProjectID: projectID, AfterID: after, Ascending: ascending, PageLimit: int32(limit + 1)}) + if err != nil { + return result, err + } + result.HasMore = len(rows) > limit + if result.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + result.Data = append(result.Data, projectKeyMetadata(row)) + } + return result, nil +} +func (s *Store) RevokeProjectAPIKey(ctx context.Context, project, id string) error { + projectID, err := parseID(project) + if err != nil { + return ErrNotFound + } + keyID, err := parseID(id) + if err != nil { + return ErrNotFound + } + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + p, err := q.LockProject(ctx, projectID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + _, err = q.RevokeProjectAPIKey(ctx, sqlc.RevokeProjectAPIKeyParams{ID: keyID, ProjectID: projectID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "revoke", "api_key", id) + }) +} +func (s *Store) ResolveProjectAPIKey(ctx context.Context, digest string) (ProjectAPIKeyBinding, error) { + if !validProjectKeyDigest(digest) { + return ProjectAPIKeyBinding{}, ErrNotFound + } + row, err := s.queries.ResolveProjectAPIKey(ctx, digest) + if errors.Is(err, pgx.ErrNoRows) { + return ProjectAPIKeyBinding{}, ErrNotFound + } + if err != nil { + return ProjectAPIKeyBinding{}, err + } + key := projectKeyMetadata(sqlc.ProjectApiKey{ID: row.ID, ProjectID: row.ProjectID, Name: row.Name, Prefix: row.Prefix, TokenSha256: row.TokenSha256, CreatedAt: row.CreatedAt, RevokedAt: row.RevokedAt}) + return ProjectAPIKeyBinding{Key: key, Principal: identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.UUID(row.TenantID.Bytes).String(), OrganizationID: row.OrganizationID, ProjectID: row.ExternalProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID}}, nil +} + +// ValidateProjectKeySeparation checks configured credentials against all stored +// digests, including revoked credentials, before the server starts. +func (s *Store) ValidateProjectKeySeparation(ctx context.Context, digests []string) error { + for _, digest := range digests { + exists, err := s.queries.ProjectAPIKeyDigestExists(ctx, digest) + if err != nil { + return err + } + if exists { + return errors.New("the Core key overlaps a persisted project API key") + } + } + return nil +} diff --git a/services/agents-api/internal/store/project_api_keys_http_test.go b/services/core/internal/store/project_api_keys_http_test.go similarity index 92% rename from services/agents-api/internal/store/project_api_keys_http_test.go rename to services/core/internal/store/project_api_keys_http_test.go index a01976abc..a0ac0c81d 100644 --- a/services/agents-api/internal/store/project_api_keys_http_test.go +++ b/services/core/internal/store/project_api_keys_http_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/project_api_keys_test.go b/services/core/internal/store/project_api_keys_test.go new file mode 100644 index 000000000..5ebe2967d --- /dev/null +++ b/services/core/internal/store/project_api_keys_test.go @@ -0,0 +1,210 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/google/uuid" +) + +func projectKeyDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} +func projectKeyPrincipal() identity.Principal { + return identity.Principal{ProjectScope: identity.ProjectScope{TenantID: uuid.NewString(), OrganizationID: "org-" + uuid.NewString(), ProjectID: "project-" + uuid.NewString()}, SubjectKind: "service_account", SubjectID: "test"} +} +func keyAdminContext(ctx context.Context, id string) context.Context { + return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "12345678", ActorLabel: "test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: id}) +} +func createTestProject(t *testing.T, s *Store) Project { + t.Helper() + id := uuid.NewString() + p, err := s.CreateProject(keyAdminContext(t.Context(), id), id, "Project") + if err != nil { + t.Fatal(err) + } + return p +} +func TestProjectKeysShareIdentityAndArchiveRetainsAssets(t *testing.T) { + s, _ := testStore(t) + p := createTestProject(t, s) + other := createTestProject(t, s) + first, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "first") + if err != nil { + t.Fatal(err) + } + second, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "second") + if err != nil { + t.Fatal(err) + } + a, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) + if err != nil { + t.Fatal(err) + } + b, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)) + if err != nil || a.Principal != b.Principal || a.Principal.SubjectID != "project:"+p.ID || a.Principal.ProjectID != "proj_"+p.ID { + t.Fatal("Project keys do not share the stable Project principal", err) + } + asset, err := s.CreateAgent(t.Context(), a.Principal.TenantID, CreateAgentInput{Configuration: []byte(`{"model":"test"}`)}) + if err != nil { + t.Fatal(err) + } + if _, err := s.GetAgent(t.Context(), b.Principal.TenantID, asset.ID); err != nil { + t.Fatal("peer key cannot read shared asset", err) + } + if _, err := s.GetAgent(t.Context(), other.TenantID, asset.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Project read asset", err) + } + renamed, err := s.RenameProject(keyAdminContext(t.Context(), p.ID), p.ID, "renamed") + if err != nil || renamed.TenantID != p.TenantID || renamed.ActiveKeyCount != 2 { + t.Fatal("rename changed Project ownership", err) + } + afterRename, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)) + if err != nil || afterRename.Principal != a.Principal { + t.Fatal("rename changed key principal", err) + } + listed, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 50, true) + if err != nil || len(listed.Data) != 2 { + t.Fatal("Project keys missing", err) + } + raw, _ := json.Marshal(listed) + if strings.Contains(string(raw), first.Key) || strings.Contains(string(raw), projectKeyDigest(first.Key)) { + t.Fatal("key list exposed credential") + } + if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), other.ID), other.ID, first.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Project revoked key", err) + } + if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, first.ID); err != nil { + t.Fatal(err) + } + if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(first.Key)); !errors.Is(err, ErrNotFound) { + t.Fatal("revoked key authenticated") + } + if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); err != nil { + t.Fatal("revocation affected peer", err) + } + archived, err := s.ArchiveProject(keyAdminContext(t.Context(), p.ID), p.ID) + if err != nil || archived.ArchivedAt == nil || archived.ActiveKeyCount != 0 { + t.Fatal("archive did not revoke all keys", err) + } + if _, err := s.ResolveProjectAPIKey(t.Context(), projectKeyDigest(second.Key)); !errors.Is(err, ErrNotFound) { + t.Fatal("archived Project authenticated") + } + if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), p.ID), p.ID, uuid.NewString(), "late"); !errors.Is(err, ErrProjectArchived) { + t.Fatal("archived Project admitted new key", err) + } + if _, err := s.GetAgent(t.Context(), p.TenantID, asset.ID); err != nil { + t.Fatal("archive removed assets", err) + } + if err := s.ValidateProjectKeySeparation(t.Context(), []string{projectKeyDigest(second.Key)}); err == nil { + t.Fatal("revoked credential collision accepted") + } + raw, _ = json.Marshal(archived) + if strings.Contains(string(raw), p.TenantID) { + t.Fatal("Project response exposed internal tenant") + } +} +func TestProjectManagementRequiresAtomicAudit(t *testing.T) { + s, _ := testStore(t) + id := uuid.NewString() + if _, err := s.CreateProject(t.Context(), id, "unaudited"); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unaudited Project accepted") + } + if _, err := s.GetProject(t.Context(), id); !errors.Is(err, ErrNotFound) { + t.Fatal("unaudited Project persisted") + } + p := createTestProject(t, s) + keyID := uuid.NewString() + if _, err := s.CreateProjectAPIKey(t.Context(), p.ID, keyID, "unaudited"); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unaudited key accepted") + } + page, err := s.ListProjectAPIKeys(t.Context(), p.ID, "", 20, true) + if err != nil || len(page.Data) != 0 { + t.Fatal("unaudited key persisted") + } + if _, err := s.RenameProject(t.Context(), p.ID, "bad"); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unaudited rename accepted") + } + if _, err := s.ArchiveProject(t.Context(), p.ID); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unaudited archive accepted") + } + current, err := s.GetProject(t.Context(), p.ID) + if err != nil || current.Project.Name != p.Name || current.Project.ArchivedAt != nil { + t.Fatal("unaudited mutation persisted") + } +} +func TestProjectNamesValidateBeforeDatabaseAccess(t *testing.T) { + s := &Store{} + for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 129)} { + if _, err := s.CreateProject(t.Context(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid Project name accepted") + } + } + for _, name := range []string{"", " ", "with\ncontrol", strings.Repeat("x", 81)} { + if _, err := s.CreateProjectAPIKey(t.Context(), uuid.NewString(), uuid.NewString(), name); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid key name accepted") + } + } +} + +func TestProjectCatalogPaginationAndScopedKeyCursor(t *testing.T) { + s, _ := testStore(t) + first, second := createTestProject(t, s), createTestProject(t, s) + if _, err := s.CreateProject(keyAdminContext(t.Context(), first.ID), first.ID, "duplicate"); !errors.Is(err, ErrProjectExists) { + t.Fatal("duplicate Project ID did not conflict", err) + } + // Equal display names do not merge Projects or keys. + a, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") + if err != nil { + t.Fatal(err) + } + b, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "same name") + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID, "duplicate ID"); !errors.Is(err, ErrProjectAPIKeyExists) { + t.Fatal("duplicate key ID did not conflict", err) + } + page, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, true) + if err != nil || len(page.Data) != 1 || !page.HasMore { + t.Fatal("first key page invalid", err) + } + tail, err := s.ListProjectAPIKeys(t.Context(), first.ID, page.Data[0].ID, 1, true) + if err != nil || len(tail.Data) != 1 || tail.HasMore || tail.Data[0].ID <= page.Data[0].ID { + t.Fatal("key cursor did not advance", err) + } + reverse, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 1, false) + if err != nil || len(reverse.Data) != 1 || reverse.Data[0].ID != tail.Data[0].ID { + t.Fatal("descending key page invalid", err) + } + if _, err := s.ListProjectAPIKeys(t.Context(), second.ID, a.ID, 1, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign key cursor accepted", err) + } + if _, err := s.ListProjectAPIKeys(t.Context(), first.ID, "", 101, true); !errors.Is(err, ErrInvalidInput) { + t.Fatal("oversized key page accepted", err) + } + if _, err := s.ListProjects(t.Context(), "", 101, true); !errors.Is(err, ErrInvalidInput) { + t.Fatal("oversized Project page accepted", err) + } + if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, b.ID); err != nil { + t.Fatal(err) + } + if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, a.ID); err != nil { + t.Fatal(err) + } + current, err := s.GetProject(t.Context(), first.ID) + if err != nil || current.Project.ArchivedAt != nil || current.Project.ActiveKeyCount != 0 { + t.Fatal("last key removal changed Project lifecycle", err) + } + if _, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), first.ID), first.ID, uuid.NewString(), "new access"); err != nil { + t.Fatal("zero-key Project could not issue another key", err) + } +} diff --git a/services/agents-api/internal/store/project_scopes_fixture_test.go b/services/core/internal/store/project_scopes_fixture_test.go similarity index 88% rename from services/agents-api/internal/store/project_scopes_fixture_test.go rename to services/core/internal/store/project_scopes_fixture_test.go index 0d8daae07..8e75fb1d4 100644 --- a/services/agents-api/internal/store/project_scopes_fixture_test.go +++ b/services/core/internal/store/project_scopes_fixture_test.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) diff --git a/services/agents-api/internal/store/project_scopes_test.go b/services/core/internal/store/project_scopes_test.go similarity index 97% rename from services/agents-api/internal/store/project_scopes_test.go rename to services/core/internal/store/project_scopes_test.go index 2d5eeac82..d47dae6ba 100644 --- a/services/agents-api/internal/store/project_scopes_test.go +++ b/services/core/internal/store/project_scopes_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/projects.go b/services/core/internal/store/projects.go similarity index 97% rename from services/agents-api/internal/store/projects.go rename to services/core/internal/store/projects.go index 3fd3613ed..bbedcb381 100644 --- a/services/agents-api/internal/store/projects.go +++ b/services/core/internal/store/projects.go @@ -9,8 +9,8 @@ import ( "unicode" "unicode/utf8" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" diff --git a/services/core/internal/store/provider_operations_fixture_test.go b/services/core/internal/store/provider_operations_fixture_test.go new file mode 100644 index 000000000..a7cae2a14 --- /dev/null +++ b/services/core/internal/store/provider_operations_fixture_test.go @@ -0,0 +1,92 @@ +package store_test + +import ( + "context" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func (*lifecycleProvider) ProviderOperations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "NewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "GetCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Suspend": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Resume": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "KillCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + } +} +func (*lifecycleProvider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { + return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { + return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { + return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { + return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) ObserveBatch(context.Context, []runtimeobs.Target) ([]runtimeobs.BatchResult, error) { + return nil, &providercontract.UnsupportedError{Operation: "ObserveBatch", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) DiscoverSelection(context.Context, sandbox.Selection) (sandbox.Selection, error) { + return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "fixture_operation_not_supported"} +} +func (*lifecycleProvider) VerifyCredential(context.Context, []sandbox.Reference) error { + return &providercontract.UnsupportedError{Operation: "VerifyCredential", Reason: "fixture_operation_not_supported"} +} +func (*fakeCheckpointProvider) ProviderOperations() providercontract.Operations { + return providercontract.Operations{ + "Create": {State: providercontract.Supported}, + "GetInfo": {State: providercontract.Supported}, + "Renew": {State: providercontract.Supported}, + "Kill": {State: providercontract.Supported}, + "RunCommand": {State: providercontract.Supported}, + "Initial": {State: providercontract.Supported}, + "NewCompute": {State: providercontract.Supported}, + "GetCompute": {State: providercontract.Supported}, + "Suspend": {State: providercontract.Supported}, + "Resume": {State: providercontract.Supported}, + "KillCompute": {State: providercontract.Supported}, + "DeleteSnapshot": {State: providercontract.Supported}, + "RunCommandCompute": {State: providercontract.Supported}, + "ResumeCompute": {State: providercontract.Supported}, + "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "ObserveBatch": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "DiscoverSelection": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "VerifyCredential": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + } +} diff --git a/services/agents-api/internal/store/provider_registration_migration_test.go b/services/core/internal/store/provider_registration_migration_test.go similarity index 100% rename from services/agents-api/internal/store/provider_registration_migration_test.go rename to services/core/internal/store/provider_registration_migration_test.go diff --git a/services/agents-api/internal/store/provisioning_failure_detail.go b/services/core/internal/store/provisioning_failure_detail.go similarity index 100% rename from services/agents-api/internal/store/provisioning_failure_detail.go rename to services/core/internal/store/provisioning_failure_detail.go diff --git a/services/agents-api/internal/store/public_execution_test.go b/services/core/internal/store/public_execution_test.go similarity index 96% rename from services/agents-api/internal/store/public_execution_test.go rename to services/core/internal/store/public_execution_test.go index 43f6bfa14..28c9a2ff7 100644 --- a/services/agents-api/internal/store/public_execution_test.go +++ b/services/core/internal/store/public_execution_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/public_url.go b/services/core/internal/store/public_url.go similarity index 100% rename from services/agents-api/internal/store/public_url.go rename to services/core/internal/store/public_url.go diff --git a/services/agents-api/internal/store/public_url_migration_test.go b/services/core/internal/store/public_url_migration_test.go similarity index 100% rename from services/agents-api/internal/store/public_url_migration_test.go rename to services/core/internal/store/public_url_migration_test.go diff --git a/services/agents-api/internal/store/remote_mcp_credentials_test.go b/services/core/internal/store/remote_mcp_credentials_test.go similarity index 97% rename from services/agents-api/internal/store/remote_mcp_credentials_test.go rename to services/core/internal/store/remote_mcp_credentials_test.go index 0a03970dd..8f3bf11e4 100644 --- a/services/agents-api/internal/store/remote_mcp_credentials_test.go +++ b/services/core/internal/store/remote_mcp_credentials_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *testing.T) { diff --git a/services/agents-api/internal/store/remote_mcp_test.go b/services/core/internal/store/remote_mcp_test.go similarity index 94% rename from services/agents-api/internal/store/remote_mcp_test.go rename to services/core/internal/store/remote_mcp_test.go index f070d0c95..fa0148db8 100644 --- a/services/agents-api/internal/store/remote_mcp_test.go +++ b/services/core/internal/store/remote_mcp_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/request_body_public_test.go b/services/core/internal/store/request_body_public_test.go similarity index 97% rename from services/agents-api/internal/store/request_body_public_test.go rename to services/core/internal/store/request_body_public_test.go index 127b61719..ea0791f25 100644 --- a/services/agents-api/internal/store/request_body_public_test.go +++ b/services/core/internal/store/request_body_public_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_adoption_test.go b/services/core/internal/store/runtime_adoption_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_adoption_test.go rename to services/core/internal/store/runtime_adoption_test.go index f1ec9bcfd..9c57015d4 100644 --- a/services/agents-api/internal/store/runtime_adoption_test.go +++ b/services/core/internal/store/runtime_adoption_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_allocation_state.go b/services/core/internal/store/runtime_allocation_state.go similarity index 98% rename from services/agents-api/internal/store/runtime_allocation_state.go rename to services/core/internal/store/runtime_allocation_state.go index 8527813b8..0ec4ef4dc 100644 --- a/services/agents-api/internal/store/runtime_allocation_state.go +++ b/services/core/internal/store/runtime_allocation_state.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) // ObserveRuntimeRunning requires verified original compute identity. It does not diff --git a/services/agents-api/internal/store/runtime_allocations.go b/services/core/internal/store/runtime_allocations.go similarity index 99% rename from services/agents-api/internal/store/runtime_allocations.go rename to services/core/internal/store/runtime_allocations.go index 6fc146545..18bccfcdf 100644 --- a/services/agents-api/internal/store/runtime_allocations.go +++ b/services/core/internal/store/runtime_allocations.go @@ -10,7 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) // RuntimeAllocation retains compute ownership, not public readiness. It survives diff --git a/services/agents-api/internal/store/runtime_allocations_test.go b/services/core/internal/store/runtime_allocations_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_allocations_test.go rename to services/core/internal/store/runtime_allocations_test.go index 0d94d34c4..f6b77b6df 100644 --- a/services/agents-api/internal/store/runtime_allocations_test.go +++ b/services/core/internal/store/runtime_allocations_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_cancellation.go b/services/core/internal/store/runtime_cancellation.go similarity index 91% rename from services/agents-api/internal/store/runtime_cancellation.go rename to services/core/internal/store/runtime_cancellation.go index 7a64eb08a..48ac6527e 100644 --- a/services/agents-api/internal/store/runtime_cancellation.go +++ b/services/core/internal/store/runtime_cancellation.go @@ -4,8 +4,8 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/runtime_capabilities_pending_test.go b/services/core/internal/store/runtime_capabilities_pending_test.go similarity index 89% rename from services/agents-api/internal/store/runtime_capabilities_pending_test.go rename to services/core/internal/store/runtime_capabilities_pending_test.go index 2cee8782d..913562f4d 100644 --- a/services/agents-api/internal/store/runtime_capabilities_pending_test.go +++ b/services/core/internal/store/runtime_capabilities_pending_test.go @@ -6,9 +6,9 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go b/services/core/internal/store/runtime_compute_lifecycle_test.go similarity index 98% rename from services/agents-api/internal/store/runtime_compute_lifecycle_test.go rename to services/core/internal/store/runtime_compute_lifecycle_test.go index 242549d3f..e7934adbc 100644 --- a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go +++ b/services/core/internal/store/runtime_compute_lifecycle_test.go @@ -13,12 +13,12 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/agents-api/internal/store/runtime_configuration_cleanup_test.go b/services/core/internal/store/runtime_configuration_cleanup_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_configuration_cleanup_test.go rename to services/core/internal/store/runtime_configuration_cleanup_test.go index 96ec6982f..32eddbe1d 100644 --- a/services/agents-api/internal/store/runtime_configuration_cleanup_test.go +++ b/services/core/internal/store/runtime_configuration_cleanup_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_connection_test.go b/services/core/internal/store/runtime_connection_test.go similarity index 91% rename from services/agents-api/internal/store/runtime_connection_test.go rename to services/core/internal/store/runtime_connection_test.go index a07c42b1b..1e2e5992d 100644 --- a/services/agents-api/internal/store/runtime_connection_test.go +++ b/services/core/internal/store/runtime_connection_test.go @@ -12,11 +12,11 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestManagedRuntimeConnectionTracksAuthenticatedSocket(t *testing.T) { diff --git a/services/agents-api/internal/store/runtime_creation_settlement_test.go b/services/core/internal/store/runtime_creation_settlement_test.go similarity index 96% rename from services/agents-api/internal/store/runtime_creation_settlement_test.go rename to services/core/internal/store/runtime_creation_settlement_test.go index e741af22a..62aade0c8 100644 --- a/services/agents-api/internal/store/runtime_creation_settlement_test.go +++ b/services/core/internal/store/runtime_creation_settlement_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_deployment.go b/services/core/internal/store/runtime_deployment.go similarity index 98% rename from services/agents-api/internal/store/runtime_deployment.go rename to services/core/internal/store/runtime_deployment.go index f4a0e5ec1..156a49108 100644 --- a/services/agents-api/internal/store/runtime_deployment.go +++ b/services/core/internal/store/runtime_deployment.go @@ -8,7 +8,7 @@ import ( "github.com/jackc/pgx/v5" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) // RuntimeDeployment identifies the one operator-selected installation for this database. diff --git a/services/agents-api/internal/store/runtime_deployment_test.go b/services/core/internal/store/runtime_deployment_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_deployment_test.go rename to services/core/internal/store/runtime_deployment_test.go index 652d06121..75c6ccc6d 100644 --- a/services/agents-api/internal/store/runtime_deployment_test.go +++ b/services/core/internal/store/runtime_deployment_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" ) func deploymentSelection() RuntimeDeployment { diff --git a/services/agents-api/internal/store/runtime_deployment_worker_test.go b/services/core/internal/store/runtime_deployment_worker_test.go similarity index 88% rename from services/agents-api/internal/store/runtime_deployment_worker_test.go rename to services/core/internal/store/runtime_deployment_worker_test.go index c8ac3c352..5a6b9ad7f 100644 --- a/services/agents-api/internal/store/runtime_deployment_worker_test.go +++ b/services/core/internal/store/runtime_deployment_worker_test.go @@ -4,10 +4,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_enrollment.go b/services/core/internal/store/runtime_enrollment.go similarity index 97% rename from services/agents-api/internal/store/runtime_enrollment.go rename to services/core/internal/store/runtime_enrollment.go index fdfdb76b1..2c56ed6aa 100644 --- a/services/agents-api/internal/store/runtime_enrollment.go +++ b/services/core/internal/store/runtime_enrollment.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/runtime_enrollment_connection_test.go b/services/core/internal/store/runtime_enrollment_connection_test.go similarity index 94% rename from services/agents-api/internal/store/runtime_enrollment_connection_test.go rename to services/core/internal/store/runtime_enrollment_connection_test.go index 89b104b47..0be93bad4 100644 --- a/services/agents-api/internal/store/runtime_enrollment_connection_test.go +++ b/services/core/internal/store/runtime_enrollment_connection_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" ) diff --git a/services/agents-api/internal/store/runtime_enrollment_test.go b/services/core/internal/store/runtime_enrollment_test.go similarity index 96% rename from services/agents-api/internal/store/runtime_enrollment_test.go rename to services/core/internal/store/runtime_enrollment_test.go index 02999941d..d19ec673f 100644 --- a/services/agents-api/internal/store/runtime_enrollment_test.go +++ b/services/core/internal/store/runtime_enrollment_test.go @@ -5,9 +5,9 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_environment_terminal.go b/services/core/internal/store/runtime_environment_terminal.go similarity index 97% rename from services/agents-api/internal/store/runtime_environment_terminal.go rename to services/core/internal/store/runtime_environment_terminal.go index 82203b5ba..5e27d8218 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal.go +++ b/services/core/internal/store/runtime_environment_terminal.go @@ -6,8 +6,8 @@ import ( "fmt" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_environment_terminal_test.go b/services/core/internal/store/runtime_environment_terminal_test.go similarity index 98% rename from services/agents-api/internal/store/runtime_environment_terminal_test.go rename to services/core/internal/store/runtime_environment_terminal_test.go index 99052e2aa..8f3c31133 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal_test.go +++ b/services/core/internal/store/runtime_environment_terminal_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/runtime_file_admission_test.go b/services/core/internal/store/runtime_file_admission_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_file_admission_test.go rename to services/core/internal/store/runtime_file_admission_test.go diff --git a/services/core/internal/store/runtime_history.go b/services/core/internal/store/runtime_history.go new file mode 100644 index 000000000..912db93ef --- /dev/null +++ b/services/core/internal/store/runtime_history.go @@ -0,0 +1,139 @@ +package store + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// InsertRuntimeHistorySample copies a sanitized periodic observation. Public +// Session/Turn Usage remains the accounting authority; these measured counters +// are only sampled chart values. Deleted or mismatched owners cannot create orphan rows. +func (s *Store) InsertRuntimeHistorySample(ctx context.Context, record runtimeobs.ExportRecord) error { + tenant, err := parseID(record.TenantID) + if err != nil { + return err + } + session, err := parseID(record.SessionID) + if err != nil { + return err + } + environment, err := parseID(record.EnvironmentID) + if err != nil { + return err + } + params := sqlc.InsertRuntimeHistorySampleParams{ + TenantID: tenant, SessionID: session, EnvironmentID: environment, + ResolvedAtNs: record.ResolvedAt.UnixNano(), ProviderType: record.ProviderType, Status: string(record.Status), + } + if record.AllocationID != "" { + params.AllocationID, err = parseID(record.AllocationID) + if err != nil { + return err + } + } + if sample := record.Sample; sample != nil { + params.ObservedAtNs = pgtype.Int8{Int64: sample.ObservedAt.UnixNano(), Valid: true} + if sample.StartedAt != nil { + params.StartedAtNs = pgtype.Int8{Int64: sample.StartedAt.UnixNano(), Valid: true} + } + params.CpuUsageSeconds = historyFloat(sample.CPUUsageSecondsTotal) + params.CpuCapacityCores = historyFloat(sample.CPUCapacityCores) + params.CpuUtilizationRatio = historyFloat(sample.CPUUtilizationRatio) + params.MemoryUsageBytes = historyInteger(sample.MemoryUsageBytes) + params.MemoryLimitBytes = historyInteger(sample.MemoryLimitBytes) + } + if usage := record.TokenUsage; usage != nil { + params.InputTokens = historyInteger(&usage.InputTokens) + params.OutputTokens = historyInteger(&usage.OutputTokens) + } + return s.queries.InsertRuntimeHistorySample(ctx, params) +} + +func (s *Store) ListRuntimeHistorySamples(ctx context.Context, tenantID, sessionID, environmentID string, startNS, endNS int64, limit int32) ([]runtimeobs.ExportRecord, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + session, err := parseID(sessionID) + if err != nil { + return nil, err + } + environment, err := parseID(environmentID) + if err != nil { + return nil, err + } + rows, err := s.queries.ListRuntimeHistorySamples(ctx, sqlc.ListRuntimeHistorySamplesParams{ + TenantID: tenant, SessionID: session, EnvironmentID: environment, StartNs: startNS, EndNs: endNS, RowLimit: limit, + }) + if err != nil { + return nil, err + } + records := make([]runtimeobs.ExportRecord, 0, len(rows)) + for _, row := range rows { + record := runtimeobs.ExportRecord{ + TenantID: tenantID, SessionID: sessionID, EnvironmentID: environmentID, + Mode: runtimeobs.ModeManaged, CollectionSource: runtimeobs.CollectionSourcePeriodic, + ResolvedAt: time.Unix(0, row.ResolvedAtNs).UTC(), ProviderType: row.ProviderType, Status: runtimeobs.Status(row.Status), + } + if row.AllocationID.Valid { + record.AllocationID = uuid.UUID(row.AllocationID.Bytes).String() + } + if row.ObservedAtNs.Valid { + record.Sample = &runtimeobs.Sample{ + ObservedAt: time.Unix(0, row.ObservedAtNs.Int64).UTC(), + CPUUsageSecondsTotal: historyFloatPointer(row.CpuUsageSeconds), CPUCapacityCores: historyFloatPointer(row.CpuCapacityCores), + CPUUtilizationRatio: historyFloatPointer(row.CpuUtilizationRatio), + MemoryUsageBytes: historyIntegerPointer(row.MemoryUsageBytes), MemoryLimitBytes: historyIntegerPointer(row.MemoryLimitBytes), + } + if row.StartedAtNs.Valid { + value := time.Unix(0, row.StartedAtNs.Int64).UTC() + record.Sample.StartedAt = &value + } + } + if row.InputTokens.Valid && row.OutputTokens.Valid { + record.TokenUsage = &runtimeobs.TokenUsage{InputTokens: uint64(row.InputTokens.Int64), OutputTokens: uint64(row.OutputTokens.Int64)} + } + records = append(records, record) + } + return records, nil +} + +func (s *Store) PruneRuntimeHistorySamples(ctx context.Context, beforeNS int64) (int64, error) { + count, err := s.queries.PruneRuntimeHistorySamples(ctx, beforeNS) + if err != nil { + return count, err + } + nodes, err := s.queries.PruneNodeHostHistory(ctx, pgtype.Timestamptz{Time: time.Unix(0, beforeNS), Valid: true}) + return count + nodes, err +} + +func historyFloat(value *float64) pgtype.Float8 { + if value == nil { + return pgtype.Float8{} + } + return pgtype.Float8{Float64: *value, Valid: true} +} +func historyInteger(value *uint64) pgtype.Int8 { + if value == nil { + return pgtype.Int8{} + } + return pgtype.Int8{Int64: int64(*value), Valid: true} +} +func historyFloatPointer(value pgtype.Float8) *float64 { + if !value.Valid { + return nil + } + return &value.Float64 +} +func historyIntegerPointer(value pgtype.Int8) *uint64 { + if !value.Valid { + return nil + } + result := uint64(value.Int64) + return &result +} diff --git a/services/agents-api/internal/store/runtime_history_acceptance_test.go b/services/core/internal/store/runtime_history_acceptance_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_history_acceptance_test.go rename to services/core/internal/store/runtime_history_acceptance_test.go index d631f837c..bd3340e56 100644 --- a/services/agents-api/internal/store/runtime_history_acceptance_test.go +++ b/services/core/internal/store/runtime_history_acceptance_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory/postgresreader" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory/postgresreader" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/runtime_idle_clock_test.go b/services/core/internal/store/runtime_idle_clock_test.go similarity index 98% rename from services/agents-api/internal/store/runtime_idle_clock_test.go rename to services/core/internal/store/runtime_idle_clock_test.go index 1d5fa0b80..0675f3d2a 100644 --- a/services/agents-api/internal/store/runtime_idle_clock_test.go +++ b/services/core/internal/store/runtime_idle_clock_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_idle_policy_test.go b/services/core/internal/store/runtime_idle_policy_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_idle_policy_test.go rename to services/core/internal/store/runtime_idle_policy_test.go diff --git a/services/core/internal/store/runtime_initialization.go b/services/core/internal/store/runtime_initialization.go new file mode 100644 index 000000000..1d38f09ad --- /dev/null +++ b/services/core/internal/store/runtime_initialization.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) requireInitializedEnvironment(ctx context.Context, tenant, session pgtype.UUID) error { + ready, err := s.queries.GetSessionInitializationReady(ctx, sqlc.GetSessionInitializationReadyParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if !ready { + return ErrNotFound + } + return nil +} diff --git a/services/agents-api/internal/store/runtime_initialization_peer_test.go b/services/core/internal/store/runtime_initialization_peer_test.go similarity index 93% rename from services/agents-api/internal/store/runtime_initialization_peer_test.go rename to services/core/internal/store/runtime_initialization_peer_test.go index 0c1a7f7b9..7cd461555 100644 --- a/services/agents-api/internal/store/runtime_initialization_peer_test.go +++ b/services/core/internal/store/runtime_initialization_peer_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/gorilla/websocket" ) diff --git a/services/core/internal/store/runtime_initialization_test.go b/services/core/internal/store/runtime_initialization_test.go new file mode 100644 index 000000000..5875a4f4d --- /dev/null +++ b/services/core/internal/store/runtime_initialization_test.go @@ -0,0 +1,197 @@ +package store_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "reflect" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" +) + +type initializingProvider struct { + lifecycleProvider + initializationPeer +} + +func (p *initializingProvider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info, err := p.lifecycleProvider.Create(ctx, b) + if err == nil { + err = p.connect(b) + } + return info, err +} +func (p *initializingProvider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + return p.initializationPeer.RunCommand(ctx, r, c) +} + +func TestEnvironmentInitializationCompletionUnknownAndRestart(t *testing.T) { + for _, mode := range []string{"complete", "restart", "uncertain", "setup-complete", "setup-restart", "setup-uncertain"} { + t.Run(mode, func(t *testing.T) { + setupOnly := strings.HasPrefix(mode, "setup-") + mode = strings.TrimPrefix(mode, "setup-") + expectedSteps := 2 + _, pool := store.NewManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} + if setupOnly { + input.InitialFiles = nil + input.Initialization = store.EnvironmentSetup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []store.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} + expectedSteps = 4 + } + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if mode == "restart" { + if _, err := pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", env.ID); err != nil { + t.Fatal(err) + } + } + p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} + p.apply = func(_ proto.RuntimePreparePayload, _ []byte) proto.RuntimePrepareResultPayload { + if _, err := s.GetSessionDevice(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Error("premature file access", err) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Error("premature execution", err) + } + if mode == "uncertain" { + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} + } + return completedInitialization(proto.RuntimePreparePayload{}, nil) + } + key := uuid.NewString() + w, stop := managedWorkerMode(t, s, key, p, false, true) + if mode == "restart" { + awaitInitialization(t, s, tenant, env.ID, "failed") + if p.writes.Load() != 0 { + t.Fatal("recovered unknown operation replayed") + } + return + } + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil { + t.Fatal(err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || !ok { + t.Fatal("preparation blocked authentication", err) + } + time.Sleep(350 * time.Millisecond) + if initializationState(t, s, tenant, env.ID) != "pending" || p.writes.Load() != 0 { + t.Fatal("missing socket consumed initialization") + } + p.deferred = false + if err := p.connect(sandbox.Bootstrap{DeviceID: owner.DeviceID, Credential: p.credential}); err != nil { + t.Fatal(err) + } + want := "complete" + if mode == "uncertain" { + want = "failed" + } + awaitInitialization(t, s, tenant, env.ID, want) + if mode == "complete" { + if int(p.writes.Load()) != expectedSteps { + t.Fatal("missing operations", p.writes.Load()) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { + t.Fatal("completed preparation blocked", err) + } + stop() + _, _ = managedWorkerMode(t, s, key, p, false, true) + time.Sleep(350 * time.Millisecond) + if int(p.writes.Load()) != expectedSteps { + t.Fatal("completed preparation replayed") + } + } else if p.writes.Load() != 1 { + t.Fatal("unknown operation replayed", p.writes.Load()) + } + p.mu.Lock() + kills := p.kills + p.mu.Unlock() + if kills != 0 || p.commandCalls.Load() != 0 { + t.Fatal("preparation changed compute lifecycle", kills, p.commandCalls.Load()) + } + }) + } +} + +func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { + s := hostedFailureStore(t) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{"proof/.codex-plugin/plugin.json": `{"name":"plugin","description":"A plugin.","skills":"./skills"}`, "proof/skills/example/SKILL.md": "---\nname: plugin-proof\ndescription: A plugin Skill.\n---\nProof."} { + file, err := writer.Create(path) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + tenant := uuid.NewString() + fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) + session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ + InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, + Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, + }) + provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} + var actions []string + var actionsMu sync.Mutex + provider.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { + if request.SessionID != session.ID || request.EnvironmentID != environment.ID { + t.Error("Runtime identity changed") + } + action := request.Action + if request.Initialization != nil { + action = request.Initialization.Action + } + if action == "file" && !bytes.Equal(data, fileBody) { + t.Error("initial bytes changed") + } + actionsMu.Lock() + actions = append(actions, action) + actionsMu.Unlock() + return completedInitialization(request, data) + } + key := uuid.NewString() + worker, _ := managedWorkerMode(t, s, key, provider, false, true) + if _, err := worker.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { + t.Fatal(err) + } + awaitInitialization(t, s, tenant, environment.ID, "complete") + actionsMu.Lock() + defer actionsMu.Unlock() + expected := []string{"file", "configure", "skill", "plugin", "npm", "python", "setup", "finalize"} + if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { + t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) + } + allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) + if err != nil || initializationState(t, s, allocation.TenantID, allocation.EnvironmentID) != "complete" { + t.Fatal("initialization incomplete", allocation, err) + } +} diff --git a/services/agents-api/internal/store/runtime_input_admission_test.go b/services/core/internal/store/runtime_input_admission_test.go similarity index 93% rename from services/agents-api/internal/store/runtime_input_admission_test.go rename to services/core/internal/store/runtime_input_admission_test.go index a1c1c658b..b616529b6 100644 --- a/services/agents-api/internal/store/runtime_input_admission_test.go +++ b/services/core/internal/store/runtime_input_admission_test.go @@ -7,8 +7,8 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestManagedRuntimeMaintenancePreservesCancelAndRetry(t *testing.T) { diff --git a/services/agents-api/internal/store/runtime_lifecycle_nodes.go b/services/core/internal/store/runtime_lifecycle_nodes.go similarity index 98% rename from services/agents-api/internal/store/runtime_lifecycle_nodes.go rename to services/core/internal/store/runtime_lifecycle_nodes.go index 3e6e60824..6f6bfaa73 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_nodes.go +++ b/services/core/internal/store/runtime_lifecycle_nodes.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go b/services/core/internal/store/runtime_lifecycle_nodes_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_lifecycle_nodes_test.go rename to services/core/internal/store/runtime_lifecycle_nodes_test.go index f9abc29ff..3e736931a 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go +++ b/services/core/internal/store/runtime_lifecycle_nodes_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/core/internal/store/runtime_lifecycle_test.go similarity index 96% rename from services/agents-api/internal/store/runtime_lifecycle_test.go rename to services/core/internal/store/runtime_lifecycle_test.go index b8374d62a..9f489fa18 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/core/internal/store/runtime_lifecycle_test.go @@ -12,11 +12,11 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Controlled provider faults exercise durable recovery, not native/model acceptance. diff --git a/services/agents-api/internal/store/runtime_node_capacity_test.go b/services/core/internal/store/runtime_node_capacity_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_node_capacity_test.go rename to services/core/internal/store/runtime_node_capacity_test.go diff --git a/services/agents-api/internal/store/runtime_node_configuration.go b/services/core/internal/store/runtime_node_configuration.go similarity index 94% rename from services/agents-api/internal/store/runtime_node_configuration.go rename to services/core/internal/store/runtime_node_configuration.go index 1a3349349..2b1cdf5b9 100644 --- a/services/agents-api/internal/store/runtime_node_configuration.go +++ b/services/core/internal/store/runtime_node_configuration.go @@ -5,9 +5,9 @@ import ( "math" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_node_deployment.go b/services/core/internal/store/runtime_node_deployment.go similarity index 94% rename from services/agents-api/internal/store/runtime_node_deployment.go rename to services/core/internal/store/runtime_node_deployment.go index 2c2a61d52..1d2691eb9 100644 --- a/services/agents-api/internal/store/runtime_node_deployment.go +++ b/services/core/internal/store/runtime_node_deployment.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_node_generations.go b/services/core/internal/store/runtime_node_generations.go similarity index 96% rename from services/agents-api/internal/store/runtime_node_generations.go rename to services/core/internal/store/runtime_node_generations.go index 057d2262d..633e4d77f 100644 --- a/services/agents-api/internal/store/runtime_node_generations.go +++ b/services/core/internal/store/runtime_node_generations.go @@ -6,9 +6,9 @@ import ( "errors" "math" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_node_generations_test.go b/services/core/internal/store/runtime_node_generations_test.go similarity index 98% rename from services/agents-api/internal/store/runtime_node_generations_test.go rename to services/core/internal/store/runtime_node_generations_test.go index 378c580b6..e3f532942 100644 --- a/services/agents-api/internal/store/runtime_node_generations_test.go +++ b/services/core/internal/store/runtime_node_generations_test.go @@ -8,8 +8,8 @@ import ( "os" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go b/services/core/internal/store/runtime_node_lifecycle_fixture_test.go similarity index 95% rename from services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go rename to services/core/internal/store/runtime_node_lifecycle_fixture_test.go index 3208ee543..85fab0ed2 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go +++ b/services/core/internal/store/runtime_node_lifecycle_fixture_test.go @@ -13,13 +13,13 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/core/internal/store/runtime_node_lifecycle_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_node_lifecycle_test.go rename to services/core/internal/store/runtime_node_lifecycle_test.go index 9ac5e812a..bbe8b36bc 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/core/internal/store/runtime_node_lifecycle_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_node_presence.go b/services/core/internal/store/runtime_node_presence.go similarity index 96% rename from services/agents-api/internal/store/runtime_node_presence.go rename to services/core/internal/store/runtime_node_presence.go index aa0c6a19d..bf6bc7670 100644 --- a/services/agents-api/internal/store/runtime_node_presence.go +++ b/services/core/internal/store/runtime_node_presence.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/runtime_node_presence_test.go b/services/core/internal/store/runtime_node_presence_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_node_presence_test.go rename to services/core/internal/store/runtime_node_presence_test.go diff --git a/services/agents-api/internal/store/runtime_node_status.go b/services/core/internal/store/runtime_node_status.go similarity index 100% rename from services/agents-api/internal/store/runtime_node_status.go rename to services/core/internal/store/runtime_node_status.go diff --git a/services/agents-api/internal/store/runtime_node_status_test.go b/services/core/internal/store/runtime_node_status_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_node_status_test.go rename to services/core/internal/store/runtime_node_status_test.go diff --git a/services/agents-api/internal/store/runtime_node_types.go b/services/core/internal/store/runtime_node_types.go similarity index 99% rename from services/agents-api/internal/store/runtime_node_types.go rename to services/core/internal/store/runtime_node_types.go index 6830dc5f8..dde61101e 100644 --- a/services/agents-api/internal/store/runtime_node_types.go +++ b/services/core/internal/store/runtime_node_types.go @@ -2,7 +2,7 @@ package store import ( "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "time" ) diff --git a/services/agents-api/internal/store/runtime_nodes.go b/services/core/internal/store/runtime_nodes.go similarity index 98% rename from services/agents-api/internal/store/runtime_nodes.go rename to services/core/internal/store/runtime_nodes.go index f3af3dd8b..3e8bf6862 100644 --- a/services/agents-api/internal/store/runtime_nodes.go +++ b/services/core/internal/store/runtime_nodes.go @@ -7,12 +7,12 @@ import ( "encoding/hex" "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/runtime_nodes_test.go b/services/core/internal/store/runtime_nodes_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_nodes_test.go rename to services/core/internal/store/runtime_nodes_test.go index 9361aa077..d11808250 100644 --- a/services/agents-api/internal/store/runtime_nodes_test.go +++ b/services/core/internal/store/runtime_nodes_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/core/internal/store/runtime_observation.go b/services/core/internal/store/runtime_observation.go new file mode 100644 index 000000000..6cbcce463 --- /dev/null +++ b/services/core/internal/store/runtime_observation.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" +) + +// RecordRuntimeObservation changes diagnostics only for the observed lifecycle revision. +// It never releases ownership, changes public readiness or authorizes replacement. +func (s *Store) RecordRuntimeObservation(ctx context.Context, owner RuntimeAllocation, diagnostic string) error { + switch diagnostic { + case "", "node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable": + default: + return ErrInvalidInput + } + if owner.NodeID == "" { + return nil + } + _, err := s.mutateRuntimeAllocation(ctx, owner, false, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { + if row.ComputeRevision != owner.ComputeRevision || row.State != owner.State || row.State == "released" { + return row, nil + } + err := q.SetRuntimeObservation(ctx, sqlc.SetRuntimeObservationParams{ID: row.ID, ComputeRevision: owner.ComputeRevision, State: owner.State, ObservationError: diagnostic}) + return row, err + }) + return err +} +func (s *Store) RuntimeNodeAvailable(ctx context.Context, node string) (bool, error) { + id, err := parseConnectionGeneration(node) + if err != nil { + return false, err + } + return runtimeNodeAvailable(ctx, s.queries, id) +} +func runtimeNodeAvailable(ctx context.Context, q *sqlc.Queries, node pgtype.UUID) (bool, error) { + nodes, err := q.ListRuntimeNodes(ctx, pgtype.UUID{}) + if err != nil { + return false, err + } + for _, n := range nodes { + if n.ID == node { + return n.Online, nil + } + } + return false, nil +} diff --git a/services/agents-api/internal/store/runtime_observation_scan_test.go b/services/core/internal/store/runtime_observation_scan_test.go similarity index 96% rename from services/agents-api/internal/store/runtime_observation_scan_test.go rename to services/core/internal/store/runtime_observation_scan_test.go index a392b45ac..3b00e6532 100644 --- a/services/agents-api/internal/store/runtime_observation_scan_test.go +++ b/services/core/internal/store/runtime_observation_scan_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestRuntimeObservationScanIsDeploymentWideBoundedAndExcludesDeleted(t *testing.T) { diff --git a/services/agents-api/internal/store/runtime_observation_test.go b/services/core/internal/store/runtime_observation_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_observation_test.go rename to services/core/internal/store/runtime_observation_test.go index 1feedffb4..b72351c2c 100644 --- a/services/agents-api/internal/store/runtime_observation_test.go +++ b/services/core/internal/store/runtime_observation_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_pending_test.go b/services/core/internal/store/runtime_pending_test.go similarity index 92% rename from services/agents-api/internal/store/runtime_pending_test.go rename to services/core/internal/store/runtime_pending_test.go index c7251aee1..82259eb92 100644 --- a/services/agents-api/internal/store/runtime_pending_test.go +++ b/services/core/internal/store/runtime_pending_test.go @@ -5,10 +5,10 @@ import ( "encoding/json" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/core/internal/store/runtime_placements.go similarity index 96% rename from services/agents-api/internal/store/runtime_placements.go rename to services/core/internal/store/runtime_placements.go index 5dddee480..c6b40a0d6 100644 --- a/services/agents-api/internal/store/runtime_placements.go +++ b/services/core/internal/store/runtime_placements.go @@ -2,9 +2,9 @@ package store import ( "context" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_scan_test.go b/services/core/internal/store/runtime_scan_test.go similarity index 96% rename from services/agents-api/internal/store/runtime_scan_test.go rename to services/core/internal/store/runtime_scan_test.go index c868670d8..5adb03224 100644 --- a/services/agents-api/internal/store/runtime_scan_test.go +++ b/services/core/internal/store/runtime_scan_test.go @@ -10,8 +10,8 @@ import ( "github.com/google/uuid" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type scanProvider struct { diff --git a/services/agents-api/internal/store/runtime_suspension.go b/services/core/internal/store/runtime_suspension.go similarity index 98% rename from services/agents-api/internal/store/runtime_suspension.go rename to services/core/internal/store/runtime_suspension.go index 67b922d89..4661292ef 100644 --- a/services/agents-api/internal/store/runtime_suspension.go +++ b/services/core/internal/store/runtime_suspension.go @@ -6,7 +6,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/runtime_suspension_concurrency_test.go b/services/core/internal/store/runtime_suspension_concurrency_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_suspension_concurrency_test.go rename to services/core/internal/store/runtime_suspension_concurrency_test.go diff --git a/services/agents-api/internal/store/runtime_suspension_test.go b/services/core/internal/store/runtime_suspension_test.go similarity index 99% rename from services/agents-api/internal/store/runtime_suspension_test.go rename to services/core/internal/store/runtime_suspension_test.go index c0b0265b3..db73eb995 100644 --- a/services/agents-api/internal/store/runtime_suspension_test.go +++ b/services/core/internal/store/runtime_suspension_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/runtime_wait_test.go b/services/core/internal/store/runtime_wait_test.go similarity index 100% rename from services/agents-api/internal/store/runtime_wait_test.go rename to services/core/internal/store/runtime_wait_test.go diff --git a/services/agents-api/internal/store/runtime_wake_hint_integration_test.go b/services/core/internal/store/runtime_wake_hint_integration_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_wake_hint_integration_test.go rename to services/core/internal/store/runtime_wake_hint_integration_test.go index d64570177..e2c4c069d 100644 --- a/services/agents-api/internal/store/runtime_wake_hint_integration_test.go +++ b/services/core/internal/store/runtime_wake_hint_integration_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) type wakeHintScanProvider struct { diff --git a/services/agents-api/internal/store/runtime_worker_recovery_test.go b/services/core/internal/store/runtime_worker_recovery_test.go similarity index 97% rename from services/agents-api/internal/store/runtime_worker_recovery_test.go rename to services/core/internal/store/runtime_worker_recovery_test.go index a03171a22..41676132a 100644 --- a/services/agents-api/internal/store/runtime_worker_recovery_test.go +++ b/services/core/internal/store/runtime_worker_recovery_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/sandbox_deployment_mutations.go b/services/core/internal/store/sandbox_deployment_mutations.go similarity index 97% rename from services/agents-api/internal/store/sandbox_deployment_mutations.go rename to services/core/internal/store/sandbox_deployment_mutations.go index df0de2d55..623dc93ef 100644 --- a/services/agents-api/internal/store/sandbox_deployment_mutations.go +++ b/services/core/internal/store/sandbox_deployment_mutations.go @@ -6,9 +6,9 @@ import ( "errors" "math" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/sandbox_deployment_resources_test.go b/services/core/internal/store/sandbox_deployment_resources_test.go similarity index 93% rename from services/agents-api/internal/store/sandbox_deployment_resources_test.go rename to services/core/internal/store/sandbox_deployment_resources_test.go index 412584321..deefa1f5f 100644 --- a/services/agents-api/internal/store/sandbox_deployment_resources_test.go +++ b/services/core/internal/store/sandbox_deployment_resources_test.go @@ -4,8 +4,8 @@ import ( "bytes" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/core/internal/store/sandbox_deployment_setup.go b/services/core/internal/store/sandbox_deployment_setup.go new file mode 100644 index 000000000..48b1a27b2 --- /dev/null +++ b/services/core/internal/store/sandbox_deployment_setup.go @@ -0,0 +1,194 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/url" + "strconv" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrSandboxDeploymentConflict = errors.New("sandbox deployment is already configured differently") + +type SandboxDeploymentSetupRequest = sandbox.Selection + +// SandboxSetup is the immutable configuration selected by the deployment admin. +// An empty Provider means that Web setup has not yet selected an adapter. +type SandboxSetup struct { + Specification sandbox.DeploymentSpec + InstallationID, Provider, BackendFingerprint string + Generation uint64 + Mode string + AdmissionPaused bool + E2B *sandbox.E2BConfiguration + IdleSeconds, RetentionSeconds int64 +} + +func (s *Store) GetSandboxSetup(ctx context.Context) (SandboxSetup, error) { + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + d, err := s.queries.GetRuntimeDeployment(ctx) + if err != nil { + return SandboxSetup{}, err + } + return s.sandboxSetup(d) +} + +func (s *Store) sandboxSetup(d sqlc.RuntimeDeployment) (SandboxSetup, error) { + if !d.WebManaged { + return SandboxSetup{}, ErrSandboxDeploymentConflict + } + result := SandboxSetup{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, BackendFingerprint: d.BackendFingerprint, IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Generation: uint64(d.Generation), Mode: d.Mode, AdmissionPaused: d.AdmissionPaused} + if err := json.Unmarshal(d.Specification, &result.Specification); err != nil { + return SandboxSetup{}, err + } + if d.ProviderKind != "" { + if err := providers.ValidateSpecification(d.ProviderKind, result.Specification); err != nil { + return SandboxSetup{}, err + } + } + if providers.UsesCredential(d.ProviderKind) { + credential, err := s.credentialCipher.OpenSandboxDeployment(d.E2bCredential, result.InstallationID, result.Generation) + if err != nil { + return SandboxSetup{}, ErrSandboxCredentialUnavailable + } + selection, err := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, DeploymentSpec: result.Specification, E2B: &sandbox.E2BConfiguration{APIKey: string(credential), Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) + if err != nil { + return SandboxSetup{}, ErrSandboxDeploymentConflict + } + result.E2B = selection.E2B + } + return result, nil +} + +// ClaimWebSandboxDeployment runs exactly once per execution-owner startup. It +// reserves the installation before selection and fences previous node presence. +func (s *Store) ClaimWebSandboxDeployment(ctx context.Context, installationID string) error { + id, err := parseConnectionGeneration(installationID) + if err != nil || s.executionLease == nil { + return ErrInvalidInput + } + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + return s.executionLease.transaction(ctx, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + d, err := q.LockRuntimeDeployment(ctx) + if err != nil { + return err + } + if d.InstallationID.Valid { + if !d.WebManaged || d.InstallationID != id { + return ErrSandboxDeploymentConflict + } + } else { + resources, err := q.CountRuntimeDeploymentResources(ctx) + if err != nil { + return err + } + if resources.Allocations != 0 || resources.Pending != 0 { + return ErrSandboxDeploymentConflict + } + } + if d.ProviderKind != "" { + if _, err := deploymentSpecification(d); err != nil { + return err + } + } + return q.ClaimWebSandboxDeployment(ctx, id) + }) +} + +// ValidateSandboxCoreURL accepts a canonical public origin, never a path or +// credential. Plain HTTP is reserved for explicit loopback development hosts. +// OAC_PUBLIC_URL must pass it. +func ValidateSandboxCoreURL(value string) error { + u, err := url.Parse(value) + if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { + return ErrInvalidInput + } + if strings.ContainsAny(u.Host, "\\% \t\r\n") || strings.HasSuffix(u.Host, ":") { + return ErrInvalidInput + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 || strconv.Itoa(n) != port { + return ErrInvalidInput + } + } + loopback := u.Hostname() == "localhost" + if ip := net.ParseIP(u.Hostname()); ip != nil { + loopback = ip.IsLoopback() + } else { + if len(u.Hostname()) > 253 || strings.ContainsAny(u.Host, "[]") { + return ErrInvalidInput + } + for _, label := range strings.Split(u.Hostname(), ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return ErrInvalidInput + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { + return ErrInvalidInput + } + } + } + } + if u.Scheme != "https" && !(u.Scheme == "http" && loopback) { + return ErrInvalidInput + } + return nil +} + +// LoopbackOrigin reports whether a validated origin names a loopback host, which +// nothing outside the Core host can reach. +func LoopbackOrigin(value string) bool { + u, err := url.Parse(value) + if err != nil { + return false + } + if ip := net.ParseIP(u.Hostname()); ip != nil { + return ip.IsLoopback() + } + return u.Hostname() == "localhost" +} + +func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) RuntimeDeploymentView { + result := RuntimeDeploymentView{InstallationID: runtimeUUID(d.InstallationID), Provider: d.ProviderKind, CoreURL: publicURL, OwnerEpoch: uint64(d.OwnerEpoch), Generation: uint64(d.Generation), Mode: d.Mode} + if len(d.Specification) > 0 && string(d.Specification) != "{}" { + var spec sandbox.DeploymentSpec + if json.Unmarshal(d.Specification, &spec) == nil { + result.Specification = &spec + result.SpecificationDigest = spec.Digest(d.ProviderKind) + } + } + if providers.UsesCredential(d.ProviderKind) { + selection, _ := providers.Restore(sandbox.Selection{Provider: d.ProviderKind, E2B: &sandbox.E2BConfiguration{Template: d.E2bTemplate, APIURL: d.E2bApiUrl, Domain: d.E2bDomain}}) + apiURL, domain := selection.E2B.APIURL, selection.E2B.Domain + result.E2B = &SandboxE2BView{Template: d.E2bTemplate, APIURL: apiURL, Domain: domain, CredentialConfigured: len(d.E2bCredential) > 0, + TemplateBuild: SandboxE2BTemplateBuildView{Resources: SandboxTemplateResources{ + CPUs: optionalInt32(d.E2bTemplateCpus), MemoryMiB: optionalInt32(d.E2bTemplateMemoryMib), RootDiskMiB: optionalInt32(d.E2bTemplateRootDiskMib)}}} + if d.E2bTemplateBuildStatus.Valid { + status := d.E2bTemplateBuildStatus.String + result.E2B.TemplateBuild.Status = &status + } + } + if providers.SupportsCheckpoint(d.ProviderKind) { + result.Suspension = &SandboxSuspensionView{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} + } + return result +} + +func optionalInt32(value pgtype.Int4) *int32 { + if !value.Valid { + return nil + } + return &value.Int32 +} diff --git a/services/agents-api/internal/store/sandbox_deployment_setup_test.go b/services/core/internal/store/sandbox_deployment_setup_test.go similarity index 100% rename from services/agents-api/internal/store/sandbox_deployment_setup_test.go rename to services/core/internal/store/sandbox_deployment_setup_test.go diff --git a/services/core/internal/store/sandbox_deployment_switch_test.go b/services/core/internal/store/sandbox_deployment_switch_test.go new file mode 100644 index 000000000..c9c41f85a --- /dev/null +++ b/services/core/internal/store/sandbox_deployment_switch_test.go @@ -0,0 +1,429 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func e2bSelection() SandboxDeploymentSetupRequest { + return SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("e2b"), Provider: "e2b", E2B: &sandbox.E2BConfiguration{APIKey: "fixture-private-api-key", Template: "runtime:" + uuid.NewString()}} +} + +func TestSandboxE2BEndpointPersistenceAndOnlineSwitch(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + input := e2bSelection() + input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" + view, err := w.InitializeSandboxDeployment(t.Context(), id, input) + if err != nil || view.E2B == nil || view.E2B.APIURL != input.E2B.APIURL || view.E2B.Domain != input.E2B.Domain { + t.Fatal("custom endpoint was not returned", view, err) + } + setup, err := s.GetSandboxSetup(t.Context()) + if err != nil || setup.E2B == nil || setup.E2B.APIURL != input.E2B.APIURL || setup.E2B.Domain != input.E2B.Domain { + t.Fatal("custom endpoint was not persisted", setup, err) + } + change := e2bSelection() + change.E2B.Template = input.E2B.Template + update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: change, ExpectedGeneration: view.Generation} + changed, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update) + if err != nil || changed.Generation != view.Generation+1 || changed.E2B == nil || changed.E2B.APIURL != "https://api.e2b.app" || changed.E2B.Domain != "e2b.app" { + t.Fatal("online endpoint switch failed", changed, err) + } +} + +func TestSandboxResetClearsCustomE2BEndpoint(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + installation := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { + t.Fatal(err) + } + input := e2bSelection() + input.E2B.APIURL, input.E2B.Domain = "https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai" + configured, err := w.InitializeSandboxDeployment(t.Context(), installation, input) + if err != nil { + t.Fatal(err) + } + ctx := SandboxResetTestContext(t.Context()) + reset, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: configured.Generation, Clear: "force"}) + if err != nil { + t.Fatal(err) + } + empty, err := w.CompleteSandboxReset(ctx, installation, configured.Generation, reset.Reset.RequestedAt) + if err != nil || empty.Provider != "" || empty.Reset != nil || empty.Generation != configured.Generation+1 { + t.Fatal("custom endpoint blocked reset completion", empty, err) + } + var apiURL, domain string + if err := pool.QueryRow(t.Context(), "SELECT e2b_api_url, e2b_domain FROM runtime_deployment").Scan(&apiURL, &domain); err != nil || apiURL != "" || domain != "" { + t.Fatal("reset retained custom endpoint", apiURL, domain, err) + } +} + +func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{4}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + input := e2bSelection() + view, err := w.InitializeSandboxDeployment(t.Context(), id, input) + if err != nil || view.Generation != 1 || view.Mode != "direct" || view.E2B == nil || !view.E2B.CredentialConfigured { + t.Fatal("direct setup", view, err) + } + raw, _ := json.Marshal(view) + if bytes.Contains(raw, []byte(input.E2B.APIKey)) { + t.Fatal("credential in public view") + } + var ciphertext []byte + if err := pool.QueryRow(t.Context(), "SELECT e2b_credential FROM runtime_deployment").Scan(&ciphertext); err != nil || bytes.Contains(ciphertext, []byte(input.E2B.APIKey)) { + t.Fatal("credential not encrypted", err) + } + setup, err := s.GetSandboxSetup(t.Context()) + if err != nil || setup.E2B.APIKey != input.E2B.APIKey { + t.Fatal("internal credential unavailable", err) + } + if _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 8}); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("cloud enrolled a machine", err) + } + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + environment := session.Environment.ID + nodes, err := w.ListRuntimeLifecycleNodes(t.Context()) + if err != nil || len(nodes) != 1 || nodes[0] != "" { + t.Fatal("cloud lifecycle requires node", nodes, err) + } + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment, id, device.HashCredential(uuid.NewString())) + if err != nil || owner.NodeID != "" { + t.Fatal(owner, err) + } + credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) + if err != nil || !ok || credential.RuntimeAllocationID != owner.ID || credential.RuntimeNodeID != "" { + t.Fatal("direct bootstrap lost managed identity", err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_allocations SET kept_at=clock_timestamp()-interval '2 hours' WHERE id=$1", owner.ID); err != nil { + t.Fatal(err) + } + observed, err := w.GetRuntimeAllocation(t.Context(), tenant, environment) + if err != nil || observed.Expired { + t.Fatal("cloud inherited legacy node-less expiry", err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + update := SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1} + if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, update); !errors.Is(err, ErrSandboxResetInProgress) { + t.Fatal("reset allowed switch", err) + } + if _, err := w.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); !errors.Is(err, ErrTurnConflict) { + t.Fatal("unsettled create released", err) + } + if _, err := w.SettleRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err := w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + changed, err := resetAndSelect(t, w, id, update.ExpectedGeneration, update.SandboxDeploymentSetupRequest) + if err != nil || changed.Generation != 3 || changed.Mode != "nodes" || changed.Reset != nil || changed.E2B != nil || changed.Resources != (SandboxDeploymentResources{}) { + t.Fatal("clean switch", changed, err) + } + if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), id, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("stale resume accepted", err) + } + if _, err := s.GetSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal("historical Session lost", err) + } +} + +func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + if _, err := w.InitializeSandboxDeployment(t.Context(), id, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { + t.Fatal(err) + } + token, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4})) + if err != nil { + t.Fatal(err) + } + node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: SandboxDeploymentTestSpec("docker").Digest("docker"), NodeID: uuid.NewString(), Name: "Machine", Provider: "docker", Credential: strings.Repeat("c", 64), BackendFingerprint: strings.Repeat("b", 64)} + if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { + t.Fatal(err) + } + unused, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 8})) + if err != nil { + t.Fatal(err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + // AdmissionPaused rejects a valid enrollment without consuming it. Authentication + // still precedes deployment details for invalid or retired credentials. + spareNode := node + spareNode.NodeID = uuid.NewString() + if _, err := s.EnrollRuntimeNode(t.Context(), unused, spareNode); !errors.Is(err, ErrSandboxResetInProgress) { + t.Fatal("reset accepted enrollment", err) + } + var consumed bool + if err := pool.QueryRow(t.Context(), "SELECT consumed_at IS NOT NULL FROM runtime_node_enrollments WHERE token_sha256=$1", runtimeTokenDigest(unused)).Scan(&consumed); err != nil || consumed { + t.Fatal("maintenance consumed enrollment", err) + } + spareNode.Provider = "microsandbox" + if _, err := s.EnrollRuntimeNode(t.Context(), strings.Repeat("invalid", 8), spareNode); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("invalid token disclosed deployment validation", err) + } + spareNode.Provider = "docker" + input := e2bSelection() + if _, err := resetAndSelect(t, w, id, 1, input); err != nil { + t.Fatal(err) + } + + if _, err := s.EnrollRuntimeNode(t.Context(), unused, spareNode); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("retired token did not reject before cloud deployment validation", err) + } + if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("old node credential survived", err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 3}); err != nil { + t.Fatal(err) + } + if _, err := resetAndSelect(t, w, id, 3, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { + t.Fatal(err) + } + if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), id, 5); err != nil { + t.Fatal(err) + } + node.NodeID = uuid.NewString() + if _, err := s.EnrollRuntimeNode(t.Context(), unused, node); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("old enrollment survived roundtrip", err) + } + nodes, err := s.ListRuntimeNodes(t.Context()) + if err != nil || len(nodes) != 0 { + t.Fatal("retired nodes reappeared", err) + } +} + +func TestSandboxResetSerializesFreshDirectSessions(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{6}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + input := e2bSelection() + if _, err := w.InitializeSandboxDeployment(t.Context(), id, input); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for range 12 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())) + if err != nil && !errors.Is(err, ErrSandboxResetAdmission) && !errors.Is(err, ErrRuntimeNodeUnavailable) { + t.Error(err) + } + }() + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), id, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + wg.Wait() + for range 3 { + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrSandboxResetAdmission) { + t.Fatal("fresh creation bypassed reset", err) + } + } + view, err := s.GetRuntimeDeployment(t.Context()) + if err != nil { + t.Fatal(err) + } + _, err = w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), id, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}, ExpectedGeneration: 1}) + if view.Resources.Pending > 0 && !errors.Is(err, ErrSandboxResetInProgress) { + t.Fatal("committed pending Session bypassed switch guard", err) + } +} + +func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + installation := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil { + t.Fatal(err) + } + selection := e2bSelection() + if _, err := w.InitializeSandboxDeployment(t.Context(), installation, selection); err != nil { + t.Fatal(err) + } + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if _, err := w.ReleaseAbsentRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + // A separate completed Session supplies public Items without calling a model. + history, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) + if err != nil { + t.Fatal(err) + } + input, err := s.SubmitMessage(t.Context(), tenant, history.ID, "history", json.RawMessage(`{"text":"retained request"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnQueued, Status: TurnInProgress}); err != nil { + t.Fatal(err) + } + if err := w.AppendTurnEvents(t.Context(), tenant, history.ID, input.TurnID, 1, []ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained answer"}`)}}); err != nil { + t.Fatal(err) + } + if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}); err != nil { + t.Fatal(err) + } + items, err := s.ListItems(t.Context(), tenant, history.ID, "", 100, true) + if err != nil || len(items.Items) != 2 { + t.Fatal("history fixture", err) + } + before, _ := json.Marshal(items) + var allocationBefore []byte + if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(a) FROM runtime_allocations a WHERE id=$1", owner.ID).Scan(&allocationBefore); err != nil { + t.Fatal(err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{Clear: "auto", ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + if _, err := resetAndSelect(t, w, installation, 1, SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { + t.Fatal(err) + } + items, err = s.ListItems(t.Context(), tenant, history.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(items) + if !bytes.Equal(before, after) { + t.Fatal("switch rewrote public Item history") + } + var allocationAfter []byte + if err := pool.QueryRow(t.Context(), "SELECT to_jsonb(a) FROM runtime_allocations a WHERE id=$1", owner.ID).Scan(&allocationAfter); err != nil { + t.Fatal(err) + } + if !bytes.Equal(allocationBefore, allocationAfter) { + t.Fatal("switch rewrote released allocation ownership") + } + for _, id := range []string{session.ID, history.ID} { + if _, err := s.GetSession(t.Context(), tenant, id); err != nil { + t.Fatal("switch lost undeleted Session", err) + } + } +} + +// Migration 000069 leaves a node-backed selection saved before specifications +// with an empty specification and its nodes with empty digests. The retained +// node reconnects to drain resources; fresh admission and node configuration +// stay closed until an administrator replaces the selection. +func TestUnspecifiedNodeDeploymentRejectedWithoutMutation(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + s := NewWithCredentialCipher(pool, cipher) + w := executionLease(t, s).Store() + id := uuid.NewString() + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err != nil { + t.Fatal(err) + } + spec := SandboxDeploymentTestSpec("docker") + selection := SandboxDeploymentSetupRequest{DeploymentSpec: spec, Provider: "docker"} + if _, err := w.InitializeSandboxDeployment(t.Context(), id, selection); err != nil { + t.Fatal(err) + } + token, err := EnrollmentTestToken(s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4})) + if err != nil { + t.Fatal(err) + } + node := RuntimeNodeEnrollment{DeploymentGeneration: 1, SpecificationDigest: spec.Digest("docker"), NodeID: uuid.NewString(), Name: "Legacy", Provider: "docker", Credential: strings.Repeat("l", 64), BackendFingerprint: strings.Repeat("b", 64)} + if _, err := s.EnrollRuntimeNode(t.Context(), token, node); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_deployment SET specification='{}'"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE runtime_nodes SET specification_digest='', deployment_generation=0"); err != nil { + t.Fatal(err) + } + + if _, err := s.GetSandboxSetup(t.Context()); err == nil { + t.Fatal("missing deployment specification accepted") + } + if _, err := s.AuthenticateRuntimeNode(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { + t.Fatal("unspecified node authenticated", err) + } + if _, err := s.RuntimeNodeConfiguration(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { + t.Fatal("node configuration served without a specification", err) + } + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrEnvironmentUnavailable) { + t.Fatal("unspecified deployment admitted a fresh sandbox", err) + } + if _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("unspecified deployment issued an enrollment token", err) + } + + epoch := managerEpoch(t, s) + if err := w.ClaimWebSandboxDeployment(t.Context(), id); err == nil { + t.Fatal("unsupported installation claimed") + } + if managerEpoch(t, s) != epoch { + t.Fatal("refused startup changed owner epoch") + } + var specification string + if err := pool.QueryRow(t.Context(), "SELECT specification::text FROM runtime_deployment").Scan(&specification); err != nil || specification != "{}" { + t.Fatal("deployment silently repaired", specification, err) + } +} diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go b/services/core/internal/store/sandbox_deployment_switch_worker_test.go similarity index 95% rename from services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go rename to services/core/internal/store/sandbox_deployment_switch_worker_test.go index 94b580d43..cc0649025 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_worker_test.go +++ b/services/core/internal/store/sandbox_deployment_switch_worker_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/sandbox_deployment_view_test.go b/services/core/internal/store/sandbox_deployment_view_test.go similarity index 95% rename from services/agents-api/internal/store/sandbox_deployment_view_test.go rename to services/core/internal/store/sandbox_deployment_view_test.go index b350bda1b..fca1dc1d3 100644 --- a/services/agents-api/internal/store/sandbox_deployment_view_test.go +++ b/services/core/internal/store/sandbox_deployment_view_test.go @@ -6,8 +6,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/sandbox_deployment_worker_test.go b/services/core/internal/store/sandbox_deployment_worker_test.go similarity index 93% rename from services/agents-api/internal/store/sandbox_deployment_worker_test.go rename to services/core/internal/store/sandbox_deployment_worker_test.go index ddb60ff50..4da5f54d0 100644 --- a/services/agents-api/internal/store/sandbox_deployment_worker_test.go +++ b/services/core/internal/store/sandbox_deployment_worker_test.go @@ -8,10 +8,10 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/sandbox_generations.go b/services/core/internal/store/sandbox_generations.go similarity index 95% rename from services/agents-api/internal/store/sandbox_generations.go rename to services/core/internal/store/sandbox_generations.go index f66db7ff1..3a96f1345 100644 --- a/services/agents-api/internal/store/sandbox_generations.go +++ b/services/core/internal/store/sandbox_generations.go @@ -5,9 +5,9 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/core/internal/store/sandbox_generations_test.go b/services/core/internal/store/sandbox_generations_test.go new file mode 100644 index 000000000..127d02f1a --- /dev/null +++ b/services/core/internal/store/sandbox_generations_test.go @@ -0,0 +1,409 @@ +package store + +import ( + "database/sql" + "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "os" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" +) + +func TestE2BGenerationsRetainOwnershipAndUseCurrentCredential(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "e2b") + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + owner := archiveAllocation(t, w, tenant, session, view.InstallationID) + ctx := SandboxResetTestContext(t.Context()) + oldTemplate := input.E2B.Template + input.E2B.Template = "next:" + uuid.NewString() + input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" + input.Resources.CPUs++ + changed, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) + if err != nil || changed.Generation != 2 || changed.OwnerEpoch != view.OwnerEpoch || changed.Rollout.PreviousGenerationSandboxes != 1 || changed.Rollout.State != "settled" { + t.Fatal(changed, err) + } + assertSandboxSnapshotEquivalent(t, s.pool) + ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} + retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) + if err != nil || retained.Generation != 1 || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" || retained.Specification.Resources.CPUs == input.Resources.CPUs { + t.Fatal(retained, err) + } + input.E2B.APIKey = "replacement-secret" + input.E2B.ReplaceCredential = true + changed, err = w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 2}) + if err != nil || changed.Generation != 3 || changed.OwnerEpoch != view.OwnerEpoch { + t.Fatal(changed, err) + } + retained, err = s.GetSandboxAllocationSetup(t.Context(), ref) + if err != nil || retained.Generation != 1 || retained.E2B.APIKey != input.E2B.APIKey || retained.E2B.Template != oldTemplate || retained.E2B.APIURL != "https://api.e2b.app" { + t.Fatal("old generation did not use committed key", err) + } + if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_allocations SET deployment_generation=3 WHERE id=$1`, owner.ID); err == nil { + t.Fatal("ownership generation was mutable") + } + if err = w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + generations, err := s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(generations) != 1 || generations[0].Generation != 1 || generations[0].E2B.APIURL != "https://api.e2b.app" { + t.Fatal(generations, err) + } + if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err = w.SettleRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err = w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if err = w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + generations, err = s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(generations) != 0 { + t.Fatal(generations, err) + } + historical, err := s.GetRuntimeAllocation(t.Context(), tenant, owner.EnvironmentID) + if err != nil || historical.DeploymentGeneration != 1 { + t.Fatal("historical generation erased", err) + } +} + +func TestE2BRetainedCustomEndpointAfterOnlineSwitch(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "e2b") + ctx := SandboxResetTestContext(t.Context()) + input.E2B.APIURL, input.E2B.Domain = "https://sandbox.example.com", "sandbox.example.com" + custom, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: view.Generation}) + if err != nil || custom.Generation != 2 { + t.Fatal(custom, err) + } + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + owner := archiveAllocation(t, w, tenant, session, view.InstallationID) + input.E2B.APIURL, input.E2B.Domain = "", "" + current, err := w.UpdateSandboxDeployment(ctx, view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: custom.Generation}) + if err != nil || current.Generation != 3 || current.E2B.APIURL != "https://api.e2b.app" { + t.Fatal(current, err) + } + ref := sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} + retained, err := s.GetSandboxAllocationSetup(t.Context(), ref) + if err != nil || retained.Generation != 2 || retained.E2B.APIURL != "https://sandbox.example.com" || retained.E2B.Domain != "sandbox.example.com" { + t.Fatal(retained, err) + } + generations, err := s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(generations) != 1 || generations[0].E2B.APIURL != "https://sandbox.example.com" { + t.Fatal(generations, err) + } +} + +func TestE2BChangeClassifierOmittedKeyAndExplicitSameKey(t *testing.T) { + _, w, view, input := webSpecificationFixture(t, "e2b") + key := input.E2B.APIKey + input.E2B.APIKey = "" + input.Resources = sandbox.Resources{} + resolved, noOp, err := w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) + if err != nil || !noOp || resolved.E2B.APIKey != key || resolved.Resources.CPUs == 0 { + t.Fatal(noOp, err) + } + input.E2B.APIKey = key + input.E2B.ReplaceCredential = true + _, noOp, err = w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}) + if err != nil || noOp { + t.Fatal("explicit same key skipped verification", err) + } + invalid := input + invalid.Runtime = &sandbox.RuntimeRelease{} + if _, _, err := w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: invalid, ExpectedGeneration: 1}); err == nil { + t.Fatal("omitted resources erased forbidden Runtime input") + } + _, _, err = w.ClassifySandboxDeploymentChange(t.Context(), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 0}) + var stale *SandboxGenerationStaleError + if !errors.As(err, &stale) { + t.Fatal("stale did not precede no-op", err) + } +} + +func TestGenerationPinRetainsOfflineZeroResourceFallback(t *testing.T) { + s, w, view, _ := webSpecificationFixture(t, "docker") + node := specificationNode(t, s, view) + if _, err := s.pool.Exec(t.Context(), `UPDATE runtime_nodes SET connection_id=NULL WHERE id=$1`, node.NodeID); err != nil { + t.Fatal(err) + } + // PR-N will make this transition through its generation protocol. This fixture + // isolates the persistence invariant without enabling node online PUT in PR-G. + tx, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(t.Context()) + q := s.queries.WithTx(tx) + if _, err = q.LockRuntimeDeployment(t.Context()); err != nil { + t.Fatal(err) + } + if err = q.RetainSandboxGeneration(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = tx.Exec(t.Context(), `UPDATE runtime_deployment SET generation=2`); err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if err = w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + rows, err := s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(rows) != 1 { + t.Fatal("offline pin was collected", rows, err) + } + nodes, err := s.ListRuntimeNodes(t.Context()) + if err != nil || len(nodes) != 1 || nodes[0].Rollout.State != "unknown" || nodes[0].Rollout.ReadyGeneration == nil || *nodes[0].Rollout.ReadyGeneration != 1 { + t.Fatal(nodes, err) + } + if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_nodes SET removed_at=clock_timestamp(),ready_generation=NULL WHERE id=$1`, node.NodeID); err != nil { + t.Fatal(err) + } + if err = w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + rows, err = s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(rows) != 0 { + t.Fatal(rows, err) + } +} + +func TestPendingPlacementGenerationSurvivesRepeatedUpdates(t *testing.T) { + s, w, view, _ := webSpecificationFixture(t, "docker") + node := specificationNode(t, s, view) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + for generation := uint64(1); generation <= 2; generation++ { + tx, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + q := s.queries.WithTx(tx) + if _, err = q.LockRuntimeDeployment(t.Context()); err != nil { + t.Fatal(err) + } + if err = q.RetainSandboxGeneration(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = tx.Exec(t.Context(), `UPDATE runtime_deployment SET generation=generation+1`); err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + } + assertSandboxSnapshotEquivalent(t, s.pool) + owner := archiveAllocation(t, w, tenant, session, view.InstallationID) + if owner.DeploymentGeneration != 1 || owner.NodeID != node.NodeID { + t.Fatal("pending allocation rebound to newest generation", owner) + } + if err := w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + rows, err := s.SandboxGenerationPage(t.Context(), -1) + if err != nil || len(rows) != 1 || rows[0].Generation != 1 { + t.Fatal(rows, err) + } + if _, err = s.pool.Exec(t.Context(), `UPDATE runtime_placements SET deployment_generation=3 WHERE environment_id=$1`, owner.EnvironmentID); err == nil { + t.Fatal("placement generation changed") + } +} + +func TestGenerationMigrationBackfillsAndRejectsLossyDowngrade(t *testing.T) { + db, migrations := runtimeNamesMigrationSchema(t) + ctx := t.Context() + if _, err := migrations.UpTo(ctx, 80); err != nil { + t.Fatal(err) + } + installation, node := uuid.NewString(), uuid.NewString() + if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET installation_id=$1,backend_fingerprint=repeat('a',64),provider_kind='docker',mode='nodes',generation=1`, installation); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,deployment_generation) VALUES($1,$2,'old',repeat('a',64),repeat('b',64),1,1,1)`, node, installation); err != nil { + t.Fatal(err) + } + session, tenant, environment, device, allocation := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + exec := func(query string, args ...any) { + t.Helper() + if _, err := db.ExecContext(ctx, query, args...); err != nil { + t.Fatal(err) + } + } + exec(`INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) VALUES($1,$2,'codex','old','old','{}')`, session, tenant) + exec(`INSERT INTO environments(id,session_id) VALUES($1,$2)`, environment, session) + exec(`INSERT INTO devices(id,tenant_id,name,credential_hash) VALUES($1,$2,'old',repeat('b',64))`, device, tenant) + exec(`INSERT INTO runtime_placements(environment_id,node_id) VALUES($1,$2)`, environment, node) + exec(`INSERT INTO runtime_allocations(id,environment_id,device_id,provider_key,node_id) VALUES($1,$2,$3,$4,$5)`, allocation, environment, device, installation, node) + if _, err := migrations.UpTo(ctx, 81); err != nil { + t.Fatal(err) + } + for _, table := range []string{"runtime_allocations", "runtime_placements"} { + var generation int64 + if err := db.QueryRowContext(ctx, `SELECT deployment_generation FROM `+table).Scan(&generation); err != nil || generation != 1 { + t.Fatal("inexact migration backfill", table, generation, err) + } + } + var pin int64 + if err := db.QueryRowContext(ctx, `SELECT ready_generation FROM runtime_nodes WHERE id=$1`, node).Scan(&pin); err != nil || pin != 1 { + t.Fatal(pin, err) + } + if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET generation=2`); err != nil { + t.Fatal(err) + } + if _, err := migrations.DownTo(ctx, 80); err == nil { + t.Fatal("downgrade erased offline serving pin") + } + if _, err := db.ExecContext(ctx, `UPDATE runtime_nodes SET removed_at=clock_timestamp(),ready_generation=NULL`); err != nil { + t.Fatal(err) + } + if _, err := migrations.DownTo(ctx, 80); err == nil { + t.Fatal("downgrade discarded held allocation/placement after pin removal") + } + exec(`UPDATE runtime_allocations SET state='released',released_at=clock_timestamp(),create_settled=true`) + if _, err := migrations.DownTo(ctx, 80); err == nil { + t.Fatal("downgrade discarded unreleased placement") + } + exec(`UPDATE runtime_placements SET released_at=clock_timestamp()`) + if _, err := migrations.DownTo(ctx, 80); err != nil { + t.Fatal("settled downgrade failed", err) + } +} + +func TestGenerationDowngradeRefusesOldAllocation(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "e2b") + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + owner := archiveAllocation(t, w, tenant, session, view.InstallationID) + input.E2B.Template = "next:" + uuid.NewString() + if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}); err != nil { + t.Fatal(err) + } + db := sql.OpenDB(stdlib.GetConnector(*s.pool.Config().ConnConfig)) + defer db.Close() + migrations, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + if _, err = migrations.DownTo(t.Context(), 80); err == nil { + t.Fatal("downgrade erased old owned allocation") + } + // Earlier down migrations can commit before the generation guard vetoes + // downgrade. Restore the current schema before invoking current Store code. + if _, err = migrations.Up(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = w.RequestRuntimeCleanup(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err = w.SettleRuntimeCreation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err = w.ReleaseRuntimeAllocation(t.Context(), owner); err != nil { + t.Fatal(err) + } + if _, err = migrations.DownTo(t.Context(), 80); err != nil { + t.Fatal("released history prevented safe downgrade", err) + } +} + +func TestGenerationUpdateSerializesWithAllocationAdmission(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "e2b") + for generation := uint64(1); generation <= 6; generation++ { + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + input.E2B.Template = "next:" + uuid.NewString() + start := make(chan struct{}) + changed := make(chan error, 1) + go func() { + <-start + _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: generation}) + changed <- err + }() + close(start) + owner := archiveAllocation(t, w, tenant, session, view.InstallationID) + if err := <-changed; err != nil { + t.Fatal(err) + } + if owner.DeploymentGeneration != generation && owner.DeploymentGeneration != generation+1 { + t.Fatal("allocation bound unrelated generation", owner.DeploymentGeneration) + } + if err := w.CollectSandboxGenerations(t.Context()); err != nil { + t.Fatal(err) + } + setup, err := s.GetSandboxAllocationSetup(t.Context(), sandbox.Reference{TenantID: tenant, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID}) + if err != nil || setup.Generation != owner.DeploymentGeneration { + t.Fatal("committed allocation lost immutable routing", err) + } + } +} + +func TestNodeRolloutSeparatesOfflinePinAndTargetReadiness(t *testing.T) { + for _, tc := range []struct { + name string + online, ready bool + enrolled, pin, target int64 + targetState, diagnostic, state string + }{ + {"offline pin", false, true, 1, 1, 2, "ready", "", "unknown"}, + {"old serving", true, true, 1, 1, 2, "ready", "", "update_required"}, + {"current serving", true, true, 2, 2, 2, "ready", "", "ready"}, + {"current failed", true, false, 2, 2, 2, "failed", "kvm_unavailable", "failed"}, + {"current unknown", true, false, 2, 2, 2, "", "", "unknown"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := nodeRollout(sqlc.ListRuntimeNodesRow{Online: tc.online, ProviderReady: tc.ready, DeploymentGeneration: tc.enrolled, ReadyGeneration: pgtype.Int8{Int64: tc.pin, Valid: true}, TargetGeneration: tc.target, ProtocolVersion: 1, TargetState: tc.targetState, TargetDiagnostic: tc.diagnostic}) + if got.State != tc.state || got.Diagnostic != tc.diagnostic || got.ReadyGeneration == nil || *got.ReadyGeneration != uint64(tc.pin) { + t.Fatal(got) + } + }) + } +} + +func TestSandboxSnapshotRolloutEquivalence(t *testing.T) { + s, w, view, input := webSpecificationFixture(t, "docker") + node := specificationNode(t, s, view) + for _, state := range []string{"ready", "preparing", "failed", "unconfirmed", "offline", "update_required"} { + t.Run(state, func(t *testing.T) { + connection := onlineManagerNode(t, s, node.NodeID) + want := SandboxRolloutNodes{} + wantState := "settled" + switch state { + case "ready": + want.Ready = 1 + case "preparing": + generationHeartbeat(t, s, node, connection, view, "preparing") + want.Preparing = 1 + wantState = "preparing" + case "failed": + generationHeartbeat(t, s, node, connection, view, "failed") + want.Failed = 1 + case "unconfirmed": + connection = uuid.NewString() + if err := s.ConnectRuntimeNode(t.Context(), node.NodeID, connection, view.OwnerEpoch); err != nil { + t.Fatal(err) + } + if err := s.HeartbeatRuntimeNodeGenerations(t.Context(), node.NodeID, connection, view.OwnerEpoch, RuntimeNodeHealth{}, nil); err != nil { + t.Fatal(err) + } + want.Unknown = 1 + case "offline": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET last_seen_at=clock_timestamp()-interval '46 seconds',health='{"diagnostic":"provider_unavailable"}' WHERE id=$1`, node.NodeID) + want.Unknown = 1 + case "update_required": + changeNodeTarget(t, w, view, input) + want.UpdateRequired = 1 + } + assertSandboxSnapshotEquivalent(t, s.pool) + got, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || got.Rollout.Nodes == nil || *got.Rollout.Nodes != want || got.Rollout.State != wantState { + t.Fatal("rollout classification changed", got.Rollout, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/sandbox_node_auth_order_http_test.go b/services/core/internal/store/sandbox_node_auth_order_http_test.go similarity index 95% rename from services/agents-api/internal/store/sandbox_node_auth_order_http_test.go rename to services/core/internal/store/sandbox_node_auth_order_http_test.go index d682102eb..2fe002ef9 100644 --- a/services/agents-api/internal/store/sandbox_node_auth_order_http_test.go +++ b/services/core/internal/store/sandbox_node_auth_order_http_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/sandbox_reset.go b/services/core/internal/store/sandbox_reset.go new file mode 100644 index 000000000..318f22bb7 --- /dev/null +++ b/services/core/internal/store/sandbox_reset.go @@ -0,0 +1,270 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "math" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrSandboxResetAdmission = errors.New("hosted admission is paused for a sandbox reset") +var ErrSandboxResetInProgress = errors.New("a sandbox reset is in progress") +var ErrSandboxNotConfigured = errors.New("the sandbox deployment is not configured") + +type SandboxGenerationStaleError struct{ CurrentGeneration uint64 } + +func (e *SandboxGenerationStaleError) Error() string { + return "the sandbox deployment generation changed" +} +func (e *SandboxGenerationStaleError) Unwrap() error { return ErrSandboxDeploymentConflict } + +type SandboxResetRequiredError struct{ CurrentProvider, RequestedProvider string } + +func (e *SandboxResetRequiredError) Error() string { + return "reset the sandbox deployment before changing its backend" +} +func (e *SandboxResetRequiredError) Unwrap() error { return ErrSandboxDeploymentConflict } + +type SandboxInUseError struct{ Resources SandboxDeploymentResources } + +func (e *SandboxInUseError) Error() string { + return "hosted sandbox resources still belong to this deployment" +} +func (e *SandboxInUseError) Unwrap() error { return ErrSandboxDeploymentConflict } + +type SandboxResetRequest struct { + ExpectedGeneration uint64 `json:"expected_generation" binding:"required" minimum:"0"` + Clear string `json:"clear" binding:"required" enums:"auto,force"` + DeadlineSeconds *int32 `json:"deadline_seconds,omitempty" minimum:"300" maximum:"86400"` +} + +func checkSandboxGeneration(d sqlc.RuntimeDeployment, installation string, generation uint64) error { + if uint64(d.Generation) != generation { + return &SandboxGenerationStaleError{uint64(d.Generation)} + } + if !d.WebManaged || runtimeUUID(d.InstallationID) != installation { + return ErrSandboxDeploymentConflict + } + return nil +} + +func (s *Store) resetTransaction(ctx context.Context, apply func(context.Context, *sqlc.Queries, sqlc.RuntimeDeployment) error) error { + if s.executionLease == nil { + return ErrInvalidInput + } + ctx, cancel := context.WithTimeout(ctx, executionTransactionTimeout) + defer cancel() + return s.executionLease.transaction(ctx, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + d, err := q.LockRuntimeDeployment(ctx) + if err != nil { + return err + } + return apply(ctx, q, d) + }) +} + +func (s *Store) StartSandboxReset(ctx context.Context, installation string, input SandboxResetRequest) (RuntimeDeploymentView, error) { + if input.Clear != "auto" && input.Clear != "force" { + return RuntimeDeploymentView{}, ErrInvalidInput + } + deadline := int32(3600) + if input.DeadlineSeconds != nil { + if input.Clear != "auto" || *input.DeadlineSeconds < 300 || *input.DeadlineSeconds > 86400 { + return RuntimeDeploymentView{}, ErrInvalidInput + } + deadline = *input.DeadlineSeconds + } + var result RuntimeDeploymentView + err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { + if err := checkSandboxGeneration(d, installation, input.ExpectedGeneration); err != nil { + return err + } + if d.ProviderKind == "" { + return ErrSandboxNotConfigured + } + if d.ResetClear.Valid { + if d.ResetClear.String != input.Clear { + if input.Clear != "force" { + return ErrSandboxResetInProgress + } + if err := q.ForceSandboxReset(ctx); err != nil { + return err + } + if err := recordDeploymentMutation(ctx, q, "reset_force", "sandbox_deployment", installation); err != nil { + return err + } + } + } else { + source, ok := adminaudit.FromContext(ctx) + if !ok { + return ErrInvalidInput + } + // The audit insert validates the provenance before this transaction + // can commit. Persist only the same non-secret typed source. + audit, err := json.Marshal(source) + if err != nil { + return err + } + if err := q.StartSandboxReset(ctx, sqlc.StartSandboxResetParams{Clear: pgtype.Text{String: input.Clear, Valid: true}, DeadlineSeconds: deadline, Audit: audit}); err != nil { + return err + } + if err := recordDeploymentMutation(ctx, q, "reset_start", "sandbox_deployment", installation); err != nil { + return err + } + } + var err error + result, err = s.deploymentView(ctx, q) + return err + }) + return result, err +} + +func (s *Store) CancelSandboxReset(ctx context.Context, installation string, generation uint64) (RuntimeDeploymentView, error) { + var result RuntimeDeploymentView + err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { + if err := checkSandboxGeneration(d, installation, generation); err != nil { + return err + } + if d.ResetClear.Valid { + if err := q.CancelSandboxReset(ctx); err != nil { + return err + } + if err := recordDeploymentMutation(ctx, q, "reset_cancel", "sandbox_deployment", installation); err != nil { + return err + } + } + var err error + result, err = s.deploymentView(ctx, q) + return err + }) + return result, err +} + +// AdvanceSandboxResetDeadline makes force escalation durable before selecting +// work. A restart cannot extend an administrator's original absolute deadline. +func (s *Store) AdvanceSandboxResetDeadline(ctx context.Context) error { + return s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { + if d.ResetClear.String != "auto" || !d.ResetDeadlineAt.Valid || time.Now().Before(d.ResetDeadlineAt.Time) { + return nil + } + source, err := sandboxResetAudit(d) + if err != nil { + return err + } + if err := q.ForceSandboxReset(ctx); err != nil { + return err + } + return recordDeploymentMutation(adminaudit.WithSource(ctx, source), q, "reset_deadline", "sandbox_deployment", runtimeUUID(d.InstallationID)) + }) +} + +func sandboxResetAudit(d sqlc.RuntimeDeployment) (adminaudit.Source, error) { + var source adminaudit.Source + if !d.ResetClear.Valid || json.Unmarshal(d.ResetAudit, &source) != nil { + return source, ErrInvalidInput + } + return source, nil +} + +// CompleteSandboxReset must run after the manager has drained. It does not take +// Session locks: archive and admission always lock Session before deployment. +func (s *Store) CompleteSandboxReset(ctx context.Context, installation string, generation uint64, requestedAt time.Time) (RuntimeDeploymentView, error) { + var result RuntimeDeploymentView + err := s.resetTransaction(ctx, func(ctx context.Context, q *sqlc.Queries, d sqlc.RuntimeDeployment) error { + if err := checkSandboxGeneration(d, installation, generation); err != nil { + return err + } + if !d.ResetClear.Valid || !d.ResetRequestedAt.Time.Equal(requestedAt) { + return ErrSandboxDeploymentConflict + } + if d.Generation == math.MaxInt64 || d.OwnerEpoch == math.MaxInt64 { + return ErrSandboxDeploymentConflict + } + resources, err := q.CountRuntimeDeploymentResources(ctx) + if err != nil { + return err + } + if resources.Allocations != 0 || resources.Pending != 0 { + return &SandboxInUseError{SandboxDeploymentResources{Allocations: resources.Allocations, Pending: resources.Pending}} + } + source, err := sandboxResetAudit(d) + if err != nil { + return err + } + if err := q.CompleteSandboxReset(ctx); err != nil { + return err + } + if err := q.RetireSandboxNodes(ctx); err != nil { + return err + } + if err := q.RetireSandboxEnrollments(ctx); err != nil { + return err + } + if err := q.ClearSandboxGenerations(ctx); err != nil { + return err + } + if err := recordDeploymentMutation(adminaudit.WithSource(ctx, source), q, "reset_complete", "sandbox_deployment", installation); err != nil { + return err + } + result, err = s.deploymentView(ctx, q) + return err + }) + return result, err +} + +// SandboxResetView contains only durable state and a single-snapshot resource partition. +type SandboxResetView struct { + Clear string `json:"clear"` + RequestedAt time.Time `json:"requested_at"` + DeadlineAt *time.Time `json:"deadline_at" extensions:"x-nullable"` + ForcedAt *time.Time `json:"forced_at" extensions:"x-nullable"` + Remaining SandboxResetRemaining `json:"remaining"` +} +type SandboxResetRemaining struct { + Busy int64 `json:"busy"` + Idle int64 `json:"idle"` + Cleanup int64 `json:"cleanup"` + OnOfflineNodes int64 `json:"on_offline_nodes"` + OfflineNodes []SandboxResetOfflineNode `json:"offline_nodes"` +} +type SandboxResetOfflineNode struct { + NodeID string `json:"node_id"` + Name string `json:"name"` + Resources int64 `json:"resources"` +} + +func resetTimestamp(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + return &value.Time +} + +type SandboxResetSession struct{ SessionID, TenantID string } + +func (s *Store) ListSandboxResetSessions(ctx context.Context, after string, force bool) ([]SandboxResetSession, error) { + cursor := pgtype.UUID{Valid: true} + if after != "" { + var err error + cursor, err = parseID(after) + if err != nil { + return nil, err + } + } + rows, err := s.queries.ListSandboxResetSessions(ctx, sqlc.ListSandboxResetSessionsParams{AfterID: cursor, Force: force}) + if err != nil { + return nil, err + } + result := make([]SandboxResetSession, 0, len(rows)) + for _, row := range rows { + result = append(result, SandboxResetSession{SessionID: runtimeUUID(row.ID), TenantID: runtimeUUID(row.TenantID)}) + } + return result, nil +} diff --git a/services/agents-api/internal/store/sandbox_reset_migration_test.go b/services/core/internal/store/sandbox_reset_migration_test.go similarity index 100% rename from services/agents-api/internal/store/sandbox_reset_migration_test.go rename to services/core/internal/store/sandbox_reset_migration_test.go diff --git a/services/core/internal/store/sandbox_reset_test.go b/services/core/internal/store/sandbox_reset_test.go new file mode 100644 index 000000000..5e6b7ca49 --- /dev/null +++ b/services/core/internal/store/sandbox_reset_test.go @@ -0,0 +1,381 @@ +package store + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/google/uuid" +) + +func SandboxResetTestContext(ctx context.Context) context.Context { + return adminaudit.WithSource(ctx, adminaudit.Source{CredentialID: "reset-fixture", ActorLabel: "operator", RequestID: "reset-request", TraceID: "reset-trace"}) +} +func resetAndSelect(t *testing.T, w *Store, installation string, generation uint64, input SandboxDeploymentSetupRequest) (RuntimeDeploymentView, error) { + t.Helper() + ctx := SandboxResetTestContext(t.Context()) + reset, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{Clear: "auto", ExpectedGeneration: generation}) + if err != nil { + return RuntimeDeploymentView{}, err + } + empty, err := w.CompleteSandboxReset(ctx, installation, generation, reset.Reset.RequestedAt) + if err != nil { + return RuntimeDeploymentView{}, err + } + input.ExpectedGeneration = empty.Generation + return w.InitializeSandboxDeployment(ctx, installation, input) +} + +func assertResetPartition(t *testing.T, view RuntimeDeploymentView) { + t.Helper() + if view.Reset == nil { + t.Fatal("missing reset") + } + remaining := view.Reset.Remaining + if remaining.Busy+remaining.Idle+remaining.Cleanup != view.Resources.Allocations+view.Resources.Pending { + t.Fatalf("inconsistent reset partition: %+v", view) + } + var offline int64 + for _, node := range remaining.OfflineNodes { + offline += node.Resources + } + if offline != remaining.OnOfflineNodes || offline > view.Resources.Allocations+view.Resources.Pending { + t.Fatalf("inconsistent offline subset: %+v", remaining) + } +} + +func TestSandboxResetAutoUsesStartedWorkAndLockedRecheck(t *testing.T) { + for _, kind := range []string{"idle", "queued", "in_progress", "waiting", "subagent_queued", "subagent_in_progress", "subagent_waiting", "file_write", "suspended", "pending"} { + t.Run(kind, func(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + var owner RuntimeAllocation + if kind != "pending" { + owner = archiveAllocation(t, w, tenant, session, installation) + } + busy := kind == "in_progress" || kind == "waiting" || kind == "subagent_in_progress" || kind == "subagent_waiting" || kind == "file_write" + switch kind { + case "queued", "in_progress", "waiting": + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,$3)`, uuid.NewString(), session.ID, kind) + case "subagent_queued", "subagent_in_progress", "subagent_waiting": + parent, child := uuid.NewString(), uuid.NewString() + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,completed_at) VALUES($1,$2,'completed',clock_timestamp())`, parent, session.ID) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, session.ID, parent) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, session.ID, owner.DeviceID, parent) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), session.ID, child, strings.TrimPrefix(kind, "subagent_")) + case "file_write": + runtimeSuspensionSQL(t, s.pool, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), session.Environment.ID, owner.DeviceID, strings.Repeat("a", 64)) + case "suspended": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_allocations SET compute_phase='suspended', compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, owner.ID) + } + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + assertResetPartition(t, reset) + assertSandboxSnapshotEquivalent(t, s.pool) + if (reset.Reset.Remaining.Busy == 1) != busy { + t.Fatalf("wrong busy classification: %+v", reset.Reset.Remaining) + } + page, err := w.ListSandboxResetSessions(t.Context(), "", false) + if err != nil || (len(page) == 0) != busy { + t.Fatal("auto eligibility", page, err) + } + _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt) + if busy { + if !errors.Is(err, ErrSandboxResetSessionBusy) { + t.Fatal("auto cut active work", err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err != nil { + t.Fatal(err) + } + _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt) + } + if err != nil { + t.Fatal("archive", err) + } + archived, err := s.GetSession(t.Context(), tenant, session.ID) + if err != nil || archived.Environment.Status != "expired" { + t.Fatal("archive not durable", archived, err) + } + var auditTenant, auditProject, credential, request string + if err := s.pool.QueryRow(t.Context(), `SELECT tenant_id::text,project_id::text,admin_credential_id,request_id FROM admin_audit_log WHERE action='archive' AND resource_id=$1`, session.ID).Scan(&auditTenant, &auditProject, &credential, &request); err != nil || auditTenant != tenant || auditProject != tenant || credential != "reset-fixture" || request != "reset-request" { + t.Fatal("background provenance was not reconstructed", err) + } + }) + } +} + +func TestSandboxResetCancellationABADeadlineAndGeneration(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + ctx := SandboxResetTestContext(t.Context()) + first, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + if first.Reset.DeadlineAt == nil || first.Reset.DeadlineAt.Sub(first.Reset.RequestedAt) < 3599*time.Second { + t.Fatal("default deadline", first) + } + shorter := int32(300) + replay, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto", DeadlineSeconds: &shorter}) + if err != nil || !replay.Reset.RequestedAt.Equal(first.Reset.RequestedAt) || !replay.Reset.DeadlineAt.Equal(*first.Reset.DeadlineAt) { + t.Fatal("retry moved durable deadline", replay, err) + } + if _, err = w.CancelSandboxReset(ctx, installation, 1); err != nil { + t.Fatal(err) + } + second, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + if _, err = w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, first.Reset.RequestedAt); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("cancelled reset archived successor work", err) + } + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_deadline_at=clock_timestamp()-interval '1 second'`) + if err := w.AdvanceSandboxResetDeadline(t.Context()); err != nil { + t.Fatal(err) + } + forced, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || forced.Reset.Clear != "force" || forced.Reset.ForcedAt == nil || !forced.Reset.RequestedAt.Equal(second.Reset.RequestedAt) { + t.Fatal("deadline not durable", forced, err) + } + if _, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}); !errors.Is(err, ErrSandboxResetInProgress) { + t.Fatal("force downgraded", err) + } + if _, err := w.StartSandboxReset(ctx, installation, SandboxResetRequest{ExpectedGeneration: 0, Clear: "force"}); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("stale reset precedence", err) + } + if _, err := w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, second.Reset.RequestedAt); err != nil { + t.Fatal(err) + } + if _, err := w.CompleteSandboxReset(ctx, installation, 1, first.Reset.RequestedAt); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("cancelled reset finalized successor", err) + } + empty, err := w.CompleteSandboxReset(ctx, installation, 1, second.Reset.RequestedAt) + if err != nil || empty.Provider != "" || empty.Generation != 2 || empty.Reset != nil || empty.InstallationID != installation || empty.E2B != nil || empty.Specification != nil { + t.Fatal("reset commit", empty, err) + } + if _, err := w.CancelSandboxReset(ctx, installation, 1); !errors.Is(err, ErrSandboxDeploymentConflict) { + t.Fatal("stale cancel", err) + } + if _, err := w.CancelSandboxReset(ctx, installation, 2); err != nil { + t.Fatal("cancel without reset", err) + } +} + +func TestSandboxResetAutoRechecksTurnStartedAfterListing(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + archiveAllocation(t, w, tenant, session, installation) + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + page, err := w.ListSandboxResetSessions(t.Context(), "", false) + if err != nil || len(page) != 1 { + t.Fatal(page, err) + } + // Existing live input remains admitted; Turn transition takes the same + // Session lock that the later conditional archive must reacquire. + turn := submitMessage(t, s, tenant, session.ID, "after-list") + transition(t, w, tenant, session.ID, turn.TurnID, TurnQueued, TurnInProgress) + if _, err := w.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt); !errors.Is(err, ErrSandboxResetSessionBusy) { + t.Fatal("listed idle candidate cut a new Turn", err) + } + before := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "turns", "runtime_placements") + if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())); !errors.Is(err, ErrSandboxResetAdmission) { + t.Fatal("new hosted admission during reset", err) + } + after := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "turns", "runtime_placements") + if !reflect.DeepEqual(before, after) { + t.Fatal("rejected admission left provisional rows") + } + if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), installation, 1); err != nil { + t.Fatal(err) + } + if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())); err != nil { + t.Fatal("cancel did not restore admission", err) + } +} + +func TestSandboxResetAuditFailureRollsBackPauseAndCompletion(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + rejectAdminAuditInsert(t, s) + rejected := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "reset-fixture", ActorLabel: "operator", RequestID: rejectedAdminRequest, TraceID: "reset-trace"}) + if _, err := w.StartSandboxReset(rejected, installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err == nil { + t.Fatal("reset committed without audit") + } + view, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || view.Reset != nil || view.Generation != 1 { + t.Fatal("failed audit left reset state", view, err) + } + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}) + if err != nil { + t.Fatal(err) + } + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_audit=jsonb_set(reset_audit,'{RequestID}',to_jsonb($1::text))`, rejectedAdminRequest) + if _, err := w.CompleteSandboxReset(t.Context(), installation, 1, reset.Reset.RequestedAt); err == nil { + t.Fatal("completion committed without audit") + } + view, err = s.GetRuntimeDeployment(t.Context()) + if err != nil || view.Reset == nil || view.Provider != "e2b" || view.Generation != 1 || view.OwnerEpoch != reset.OwnerEpoch { + t.Fatal("failed completion destroyed committed provider", view, err) + } +} + +func TestSandboxResetSnapshotCountsOfflineOwnershipOnce(t *testing.T) { + s, w, deployment := managerFixture(t, 10, 10) + // Reuse the real placement fixture, then adopt its selection as Web-managed. + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET web_managed=true,local_node_id=NULL`) + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET deployment_generation=1,specification_digest=$1`, SandboxDeploymentTestSpec("docker").Digest("docker")) + _, pending := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + tenant, suspended := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + allocation := archiveAllocation(t, w, tenant, suspended, deployment.InstallationID) + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_allocations SET compute_phase='suspended',compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, allocation.ID) + tenant, deleted := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + archiveAllocation(t, w, tenant, deleted, deployment.InstallationID) + if err := s.DeleteSession(t.Context(), tenant, deleted.ID); err != nil { + t.Fatal(err) + } + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), deployment.InstallationID, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + assertResetPartition(t, reset) + if reset.Resources != (SandboxDeploymentResources{Allocations: 2, Pending: 1}) || reset.Reset.Remaining.Idle != 2 || reset.Reset.Remaining.Cleanup != 1 { + t.Fatal("duplicated placement or missing deleted receipt", reset) + } + for _, state := range []string{"preparing", "stale", "epoch", "disconnected"} { + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connected_epoch=(SELECT owner_epoch FROM runtime_deployment)`) + onlineManagerNode(t, s, deployment.LocalNodeID) + switch state { + case "preparing": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET provider_ready=false`) + case "stale": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET last_seen_at=clock_timestamp()-interval '46 seconds'`) + case "epoch": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connected_epoch=connected_epoch+1`) + case "disconnected": + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_nodes SET connection_id=NULL`) + } + view, err := s.GetRuntimeDeployment(t.Context()) + if err != nil { + t.Fatal(err) + } + assertResetPartition(t, view) + assertSandboxSnapshotEquivalent(t, s.pool) + want := int64(3) + if state == "preparing" { + want = 0 + } + if view.Reset.Remaining.OnOfflineNodes != want { + t.Fatalf("%s presence: %+v", state, view.Reset.Remaining) + } + if want > 0 && (len(view.Reset.Remaining.OfflineNodes) != 1 || view.Reset.Remaining.OfflineNodes[0].NodeID != deployment.LocalNodeID || view.Reset.Remaining.OfflineNodes[0].Resources != 3) { + t.Fatal("offline ownership projection", view.Reset.Remaining) + } + } + if _, err := w.CompleteSandboxReset(t.Context(), deployment.InstallationID, 1, reset.Reset.RequestedAt); err == nil { + t.Fatal("offline resources were treated as cleaned") + } + if err := s.RemoveRuntimeNode(t.Context(), deployment.LocalNodeID); !errors.Is(err, ErrRuntimeNodeInUse) { + t.Fatal("removed node with reset resources", err) + } + if row, err := s.GetEnvironment(t.Context(), pending.TenantID, pending.Environment.ID); err == nil && row.Status == "expired" { + t.Fatal("projection mutated pending resource") + } +} + +func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + // Use deterministic ordered UUIDs so more than one page of busy rows precedes + // idle work. Listing must not repeatedly return the busy prefix. + for i := 1; i <= 35; i++ { + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + id := fmt.Sprintf("00000000-0000-4000-8000-%012d", i) + // IDs are immutable API identities, so seed this bounded scheduling fixture + // directly instead of modifying an existing Session primary key. + runtimeSuspensionSQL(t, s.pool, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) SELECT $1::uuid,tenant_id,engine,$1::text,$1::text,configuration FROM sessions WHERE id=$2`, id, session.ID) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO environments(id,session_id) VALUES($1,$2)`, uuid.NewString(), id) + if i <= 34 { + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status) VALUES($1,$2,'in_progress')`, uuid.NewString(), id) + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + } + selfTenant, self := managedArchiveSession(t, s, environmentInput(uuid.NewString(), "self_hosted", "/workspace")) + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + page, err := w.ListSandboxResetSessions(t.Context(), "", false) + if err != nil || len(page) != 1 || page[0].SessionID != "00000000-0000-4000-8000-000000000035" { + t.Fatal("busy prefix starved idle work", page, err) + } + if _, err := w.ArchiveSandboxResetSession(t.Context(), page[0].TenantID, page[0].SessionID, 1, reset.Reset.RequestedAt); err != nil { + t.Fatal(err) + } + if _, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "force"}); err != nil { + t.Fatal(err) + } + first, err := w.ListSandboxResetSessions(t.Context(), "", true) + if err != nil || len(first) != 32 { + t.Fatal(first, err) + } + second, err := w.ListSandboxResetSessions(t.Context(), first[31].SessionID, true) + if err != nil || len(second) != 2 { + t.Fatal(second, err) + } + if _, err := w.ArchiveSandboxResetSession(t.Context(), selfTenant, self.ID, 1, reset.Reset.RequestedAt); !errors.Is(err, ErrInvalidInput) { + t.Fatal("self-hosted reset archive", err) + } + view, err := s.GetSession(t.Context(), selfTenant, self.ID) + if err != nil || view.Environment.Status == "expired" { + t.Fatal("reset changed self-hosted Session", view, err) + } +} + +func TestSandboxResetOwnerRestartRetainsDeadlineAndProvenance(t *testing.T) { + s, w, installation := managedArchiveFixture(t) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) + reset, err := w.StartSandboxReset(SandboxResetTestContext(t.Context()), installation, SandboxResetRequest{ExpectedGeneration: 1, Clear: "auto"}) + if err != nil { + t.Fatal(err) + } + // Model an abrupt owner loss and wait for PostgreSQL to terminate that backend, + // rather than assuming local TCP cleanup acknowledges advisory-lock release. + var stopped bool + if err := s.pool.QueryRow(t.Context(), `SELECT pg_terminate_backend($1,1000)`, w.executionLease.conn.Conn().PgConn().PID()).Scan(&stopped); err != nil || !stopped { + t.Fatal(stopped, err) + } + successor := executionLease(t, s).Store() + current, err := s.GetRuntimeDeployment(t.Context()) + if err != nil || !current.Reset.RequestedAt.Equal(reset.Reset.RequestedAt) || !current.Reset.DeadlineAt.Equal(*reset.Reset.DeadlineAt) { + t.Fatal("restart moved reset deadline", current, err) + } + if _, err := w.CancelSandboxReset(SandboxResetTestContext(t.Context()), installation, 1); err == nil { + t.Fatal("detached writer cancelled successor reset") + } + runtimeSuspensionSQL(t, s.pool, `UPDATE runtime_deployment SET reset_deadline_at=clock_timestamp()-interval '1 second'`) + if err := successor.AdvanceSandboxResetDeadline(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := successor.ArchiveSandboxResetSession(t.Context(), tenant, session.ID, 1, reset.Reset.RequestedAt); err != nil { + t.Fatal(err) + } + empty, err := successor.CompleteSandboxReset(t.Context(), installation, 1, reset.Reset.RequestedAt) + if err != nil || empty.Generation != 2 || empty.Provider != "" { + t.Fatal(empty, err) + } + var actions []string + if err := s.pool.QueryRow(t.Context(), `SELECT array_agg(action ORDER BY action) FROM admin_audit_log WHERE action IN ('reset_start','reset_deadline','reset_complete') AND request_id='reset-request' AND admin_credential_id='reset-fixture'`).Scan(&actions); err != nil || len(actions) != 3 { + t.Fatal("restart lost requester audit", actions, err) + } +} diff --git a/services/agents-api/internal/store/sandbox_snapshot_plan_test.go b/services/core/internal/store/sandbox_snapshot_plan_test.go similarity index 100% rename from services/agents-api/internal/store/sandbox_snapshot_plan_test.go rename to services/core/internal/store/sandbox_snapshot_plan_test.go diff --git a/services/agents-api/internal/store/sandbox_specification.go b/services/core/internal/store/sandbox_specification.go similarity index 89% rename from services/agents-api/internal/store/sandbox_specification.go rename to services/core/internal/store/sandbox_specification.go index ce7041d4e..21dceb204 100644 --- a/services/agents-api/internal/store/sandbox_specification.go +++ b/services/core/internal/store/sandbox_specification.go @@ -4,9 +4,9 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" ) // SandboxConfigurationError contains only validated, non-secret configuration diagnostics. diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/core/internal/store/sandbox_specification_store_test.go similarity index 99% rename from services/agents-api/internal/store/sandbox_specification_store_test.go rename to services/core/internal/store/sandbox_specification_store_test.go index 14b421d68..47a5e3c93 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/core/internal/store/sandbox_specification_store_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/sandbox_specification_test.go b/services/core/internal/store/sandbox_specification_test.go similarity index 92% rename from services/agents-api/internal/store/sandbox_specification_test.go rename to services/core/internal/store/sandbox_specification_test.go index 7ac27da68..3bfcf557b 100644 --- a/services/agents-api/internal/store/sandbox_specification_test.go +++ b/services/core/internal/store/sandbox_specification_test.go @@ -1,7 +1,7 @@ package store import ( - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "strings" ) diff --git a/services/agents-api/internal/store/saved_web_search_public_test.go b/services/core/internal/store/saved_web_search_public_test.go similarity index 98% rename from services/agents-api/internal/store/saved_web_search_public_test.go rename to services/core/internal/store/saved_web_search_public_test.go index aad724750..2bd0224fb 100644 --- a/services/agents-api/internal/store/saved_web_search_public_test.go +++ b/services/core/internal/store/saved_web_search_public_test.go @@ -8,9 +8,9 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/scheduling.go b/services/core/internal/store/scheduling.go similarity index 98% rename from services/agents-api/internal/store/scheduling.go rename to services/core/internal/store/scheduling.go index 8636fbc7b..dc9300fa6 100644 --- a/services/agents-api/internal/store/scheduling.go +++ b/services/core/internal/store/scheduling.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/self_hosted_cancel_public_test.go b/services/core/internal/store/self_hosted_cancel_public_test.go similarity index 97% rename from services/agents-api/internal/store/self_hosted_cancel_public_test.go rename to services/core/internal/store/self_hosted_cancel_public_test.go index 3ba53a723..8719b8a2c 100644 --- a/services/agents-api/internal/store/self_hosted_cancel_public_test.go +++ b/services/core/internal/store/self_hosted_cancel_public_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/self_hosted_initial_public_test.go b/services/core/internal/store/self_hosted_initial_public_test.go similarity index 96% rename from services/agents-api/internal/store/self_hosted_initial_public_test.go rename to services/core/internal/store/self_hosted_initial_public_test.go index b08853ad9..7f035146e 100644 --- a/services/agents-api/internal/store/self_hosted_initial_public_test.go +++ b/services/core/internal/store/self_hosted_initial_public_test.go @@ -15,11 +15,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/session_agent_filter_public_test.go b/services/core/internal/store/session_agent_filter_public_test.go similarity index 88% rename from services/agents-api/internal/store/session_agent_filter_public_test.go rename to services/core/internal/store/session_agent_filter_public_test.go index 2d6cdd163..7e91b48d0 100644 --- a/services/agents-api/internal/store/session_agent_filter_public_test.go +++ b/services/core/internal/store/session_agent_filter_public_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_agent_filter_test.go b/services/core/internal/store/session_agent_filter_test.go similarity index 100% rename from services/agents-api/internal/store/session_agent_filter_test.go rename to services/core/internal/store/session_agent_filter_test.go diff --git a/services/core/internal/store/session_artifacts.go b/services/core/internal/store/session_artifacts.go new file mode 100644 index 000000000..3be7a5b53 --- /dev/null +++ b/services/core/internal/store/session_artifacts.go @@ -0,0 +1,170 @@ +package store + +import ( + "context" + "errors" + "io" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type SessionArtifact struct { + ID string + SessionID string + TurnID string + EnvironmentID string + Path string + SizeBytes int64 + CreatedAt time.Time +} + +type ArtifactPage struct { + Artifacts []SessionArtifact + NextCursor string +} + +func (s *Store) GetSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) (SessionArtifact, error) { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return SessionArtifact{}, err + } + row, err := s.queries.GetSessionArtifact(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return SessionArtifact{}, ErrNotFound + } + return artifactFromRow(row), err +} + +func (s *Store) ListSessionArtifacts(ctx context.Context, tenantID, sessionID, environmentID, cursor string, limit int, ascending bool) (ArtifactPage, error) { + if limit < 1 || limit > 100 { + return ArtifactPage{}, ErrInvalidInput + } + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return ArtifactPage{}, err + } + tenant, _ := parseID(tenantID) + session, _ := parseID(sessionID) + params := sqlc.ListSessionArtifactsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} + if environmentID != "" { + // A malformed filter matches nothing, like another Environment's ID (HE-56). + params.EnvironmentID = parsePathID(environmentID) + } + if cursor != "" { + // Any cursor that is not an Artifact of this Session, including a + // malformed one, is an invalid cursor rather than a missing resource. + after, err := s.GetSessionArtifact(ctx, tenantID, sessionID, cursor) + if errors.Is(err, ErrNotFound) { + // The Session lookup above is a separate statement: a Session deleted + // since then stays not found. Deleted Sessions never reappear, so an + // existing one here also existed when the cursor was read. + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return ArtifactPage{}, err + } + return ArtifactPage{}, errArtifactCursor + } + if err != nil { + return ArtifactPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListSessionArtifacts(ctx, params) + if err != nil { + return ArtifactPage{}, err + } + page := ArtifactPage{Artifacts: make([]SessionArtifact, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + page.Artifacts = append(page.Artifacts, artifactFromRow(row)) + } + return page, nil +} + +// ReadSessionArtifact keeps an admitted snapshot available across concurrent deletion. +func (s *Store) ReadSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string, consume func(SessionArtifact, io.Reader) error) error { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return err + } + if consume == nil { + return ErrInvalidInput + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + row, err := s.queries.WithTx(tx).GetSessionArtifact(ctx, lookup) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) + if err != nil { + return err + } + if err := consume(artifactFromRow(row), body); err != nil { + return err + } + if err := body.Close(); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) DeleteSessionArtifact(ctx context.Context, tenantID, sessionID, artifactID string) error { + lookup, err := artifactLookup(tenantID, sessionID, artifactID) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + q := s.queries.WithTx(tx) + // Use the same lock order as whole-Session deletion and Turn publication. + locked, err := q.LockSession(ctx, sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}) + if errors.Is(err, pgx.ErrNoRows) || err == nil && locked.DeletedAt.Valid { + return ErrNotFound + } + if err != nil { + return err + } + oid, err := q.DeleteSessionArtifact(ctx, sqlc.DeleteSessionArtifactParams(lookup)) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + if err := objects.Unlink(ctx, oid.Uint32); err != nil { + return err + } + if err := recordWriteAudit(ctx, q, tenantID, "delete", "artifact", uuid.UUID(lookup.ID.Bytes).String(), uuid.UUID(lookup.SessionID.Bytes).String()); err != nil { + return err + } + return tx.Commit(ctx) +} + +func artifactLookup(tenantID, sessionID, artifactID string) (sqlc.GetSessionArtifactParams, error) { + ids, err := publicTurnLookup(tenantID, sessionID, artifactID) + return sqlc.GetSessionArtifactParams{TenantID: ids.TenantID, SessionID: ids.SessionID, ID: ids.ID}, err +} + +func artifactFromRow(row sqlc.SessionArtifact) SessionArtifact { + return SessionArtifact{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), + TurnID: uuid.UUID(row.TurnID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), + Path: row.Path, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/agents-api/internal/store/session_artifacts_public_test.go b/services/core/internal/store/session_artifacts_public_test.go similarity index 98% rename from services/agents-api/internal/store/session_artifacts_public_test.go rename to services/core/internal/store/session_artifacts_public_test.go index 7b97ab637..e336d7a46 100644 --- a/services/agents-api/internal/store/session_artifacts_public_test.go +++ b/services/core/internal/store/session_artifacts_public_test.go @@ -16,9 +16,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/session_artifacts_test.go b/services/core/internal/store/session_artifacts_test.go new file mode 100644 index 000000000..3e81ae64a --- /dev/null +++ b/services/core/internal/store/session_artifacts_test.go @@ -0,0 +1,580 @@ +package store + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" +) + +func artifactArchive(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var data bytes.Buffer + w := tar.NewWriter(&data) + for name, body := range files { + if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := w.Write(body); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func artifactTurn(t *testing.T, s *Store, kind string) (tenant, session, environment, turn string) { + t.Helper() + tenant = uuid.NewString() + created, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact", kind, "/workspace")) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) + if err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, created.ID, "artifact-turn") + transition(t, s, tenant, created.ID, input.TurnID, TurnQueued, TurnInProgress) + return tenant, created.ID, env.ID, input.TurnID +} + +func TestSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T) { + for _, kind := range []string{"openai_hosted", "self_hosted"} { + t.Run(kind, func(t *testing.T) { testSessionArtifactsPublishVersionScopeAndLifetime(t, kind) }) + } +} + +func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind string) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, kind) + before := sourceObjectCount(t, pool) + data := bytes.Repeat([]byte("immutable\x00"), 100000) + archive := artifactArchive(t, map[string][]byte{"outputs/a.bin": data, "outputs/nested/empty": {}}) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || len(page.Artifacts) != 0 { + t.Fatalf("private capture visible: %+v %v", page, err) + } + completed := transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + page, err = s.ListSessionArtifacts(t.Context(), tenant, session, environment, "", 100, true) + if err != nil || len(page.Artifacts) != 2 { + t.Fatalf("published capture: %+v %v", page, err) + } + for _, a := range page.Artifacts { + if a.SessionID != session || a.TurnID != turn || a.EnvironmentID != environment || !a.CreatedAt.Equal(completed.CompletedAt) { + t.Fatalf("publication metadata: %+v", a) + } + foreign := uuid.NewString() + if _, err := s.GetSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign metadata: %v", err) + } + if _, err := s.GetSessionArtifact(t.Context(), tenant, uuid.NewString(), a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong session metadata: %v", err) + } + if err := s.DeleteSessionArtifact(t.Context(), foreign, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete: %v", err) + } + if err := s.ReadSessionArtifact(t.Context(), foreign, session, a.ID, func(SessionArtifact, io.Reader) error { + t.Error("foreign read reached content") + return nil + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign read: %v", err) + } + } + if _, err := s.ListSessionArtifacts(t.Context(), uuid.NewString(), session, "", "", 100, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign list: %v", err) + } + if empty, err := s.ListSessionArtifacts(t.Context(), tenant, session, uuid.NewString(), "", 100, false); err != nil || len(empty.Artifacts) != 0 { + t.Fatalf("environment filter: %+v %v", empty, err) + } + // A later completed Turn publishes another immutable version of the same path. + next := submitMessage(t, s, tenant, session, "version-two") + transition(t, s, tenant, session, next.TurnID, TurnQueued, TurnInProgress) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, next.TurnID, environment, bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/a.bin": []byte("new")}))); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, next.TurnID, TurnInProgress, TurnCompleted) + all, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || len(all.Artifacts) != 3 { + t.Fatal(all, err) + } + for _, asc := range []bool{true, false} { + var got []SessionArtifact + cursor := "" + for { + part, err := s.ListSessionArtifacts(t.Context(), tenant, session, environment, cursor, 1, asc) + if err != nil { + t.Fatal(err) + } + got = append(got, part.Artifacts...) + if part.NextCursor == "" { + break + } + if len(got) > 3 { + t.Fatal("pagination repeated artifacts") + } + cursor = part.NextCursor + } + want := append([]SessionArtifact(nil), all.Artifacts...) + if !asc { + want[0], want[2] = want[2], want[0] + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ordered pages differ: %+v %+v", got, want) + } + } + // Expiration is a controlled fixture; stored reads must not touch Runtime. + if _, err := pool.Exec(t.Context(), "UPDATE environments SET status='expired' WHERE id=$1", environment); err != nil { + t.Fatal(err) + } + for _, a := range page.Artifacts { + if err := New(pool).ReadSessionArtifact(t.Context(), tenant, session, a.ID, func(meta SessionArtifact, r io.Reader) error { + if err := s.DeleteSessionArtifact(t.Context(), tenant, session, a.ID); err != nil { + return err + } + body, err := io.ReadAll(r) + want := data + if a.Path == "/workspace/outputs/nested/empty" { + want = nil + } + if meta != a || !bytes.Equal(body, want) { + t.Error("expired/deleted artifact damaged admitted snapshot") + } + return err + }); err != nil { + t.Fatal(err) + } + if _, err := s.GetSessionArtifact(t.Context(), tenant, session, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted metadata retained: %v", err) + } + } + if err := s.DeleteSession(t.Context(), tenant, session); err != nil { + t.Fatal(err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("objects leaked: %d -> %d", before, count) + } +} + +type artifactReadError struct{} + +func (artifactReadError) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T) { + for _, kind := range []string{"openai_hosted", "self_hosted"} { + t.Run(kind, func(t *testing.T) { testSessionArtifactsRejectIncompleteAndUnownedCapture(t, kind) }) + } +} + +func testSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T, kind string) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, kind) + before := sourceObjectCount(t, pool) + valid := artifactArchive(t, map[string][]byte{"outputs/a": []byte("data")}) + for name, body := range map[string]io.Reader{ + "transport-failure-after-valid-tar": io.MultiReader(bytes.NewReader(valid), artifactReadError{}), + "truncated-body": bytes.NewReader(valid[:513]), + "trailing-data": io.MultiReader(bytes.NewReader(valid), bytes.NewReader([]byte("not archive padding"))), + "traversal": bytes.NewReader(artifactArchive(t, map[string][]byte{"outputs/../secret": []byte("no")})), + "private-root": bytes.NewReader(artifactArchive(t, map[string][]byte{"secrets/key": []byte("no")})), + } { + t.Run(name, func(t *testing.T) { + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, body); err == nil { + t.Fatal("invalid capture accepted") + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("rollback leaked objects: %d -> %d", before, count) + } + }) + } + for _, ids := range [][4]string{{uuid.NewString(), session, turn, environment}, {tenant, session, turn, uuid.NewString()}} { + if err := s.StageTurnArtifacts(t.Context(), ids[0], ids[1], ids[2], ids[3], artifactReadError{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("unauthorized capture reached reader: %v", err) + } + } +} + +func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { + for _, status := range []string{TurnFailed, TurnCancelled} { + t.Run(status, func(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + body := artifactArchive(t, map[string][]byte{"outputs/a": []byte("private")}) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, turn, TurnInProgress, status) + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("terminal capture leaked objects: %d -> %d", before, count) + } + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(body)); !errors.Is(err, ErrTurnConflict) { + t.Fatalf("late capture accepted: %v", err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("late capture leaked objects: %d -> %d", before, count) + } + }) + } +} + +func TestSessionArtifactTransferDoesNotBlockDeletionOrCancellation(t *testing.T) { + for _, operation := range []string{"delete", "cancel"} { + t.Run(operation, func(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + result := make(chan error, 1) + go func() { result <- s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, reader) }() + // A complete TAR arrives, but transport has not acknowledged success yet. + if _, err := writer.Write(artifactArchive(t, map[string][]byte{"outputs/a": []byte("partial")})); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + want := ErrNotFound + if operation == "delete" { + // The idle-only decision itself is not blocked by the transfer. + if err := s.DeleteSession(ctx, tenant, session); !errors.Is(err, ErrSessionNotIdle) { + t.Fatalf("transfer blocked or bypassed the deletion rule: %v", err) + } + if err := s.commitLegacyDeletion(ctx, tenant, session); err != nil { + t.Fatalf("transfer blocked deletion: %v", err) + } + } else { + if _, err := s.RequestCancel(ctx, tenant, session, "cancel-capture"); err != nil { + t.Fatalf("transfer blocked cancellation: %v", err) + } + want = ErrTurnConflict + } + writer.Close() + if err := <-result; !errors.Is(err, want) { + t.Fatalf("late publication after %s: %v", operation, err) + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("late capture leaked objects: %d -> %d", before, count) + } + }) + } +} + +// startArtifactTurn admits another message and starts its Turn. +func startArtifactTurn(t *testing.T, s *Store, tenant, session, key string) string { + t.Helper() + input := submitMessage(t, s, tenant, session, key) + transition(t, s, tenant, session, input.TurnID, TurnQueued, TurnInProgress) + return input.TurnID +} + +// stageArtifactOutputs privately captures one complete outputs tree for a Turn. +func stageArtifactOutputs(t *testing.T, s *Store, tenant, session, environment, turn string, files map[string]string) { + t.Helper() + archive := make(map[string][]byte, len(files)) + for name, body := range files { + archive["outputs/"+name] = []byte(body) + } + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, environment, bytes.NewReader(artifactArchive(t, archive))); err != nil { + t.Fatal(err) + } +} + +// publishedByTurn returns the Artifacts one Turn published, keyed by outputs-relative path. +func publishedByTurn(t *testing.T, s *Store, tenant, session, turn string) map[string]SessionArtifact { + t.Helper() + page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || page.NextCursor != "" { + t.Fatalf("list: %+v %v", page, err) + } + got := make(map[string]SessionArtifact) + for _, artifact := range page.Artifacts { + if artifact.TurnID == turn { + got[strings.TrimPrefix(artifact.Path, "/workspace/outputs/")] = artifact + } + } + return got +} + +func artifactBytes(t *testing.T, s *Store, tenant, session, id string) string { + t.Helper() + var body []byte + if err := s.ReadSessionArtifact(t.Context(), tenant, session, id, func(_ SessionArtifact, r io.Reader) error { + var err error + body, err = io.ReadAll(r) + return err + }); err != nil { + t.Fatal(err) + } + return string(body) +} + +func publishedPaths(published map[string]SessionArtifact) []string { + paths := make([]string, 0, len(published)) + for path := range published { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +// Later Turns publish a path only when it is new, its bytes differ from the +// newest remaining Artifact for that path, or no Artifact remains for it (HE-52). +func TestSessionArtifactsRepublishOnlyNewChangedOrDeletedPaths(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, first := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + turnNumber := 1 + run := func(files map[string]string, want ...string) map[string]SessionArtifact { + t.Helper() + turn := first + if turnNumber > 1 { + turn = startArtifactTurn(t, s, tenant, session, fmt.Sprintf("artifact-turn-%d", turnNumber)) + } + turnNumber++ + stageArtifactOutputs(t, s, tenant, session, environment, turn, files) + transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + published := publishedByTurn(t, s, tenant, session, turn) + sort.Strings(want) + if got := publishedPaths(published); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("Turn %d published %v, want %v", turnNumber-1, got, want) + } + for path, artifact := range published { + if body := artifactBytes(t, s, tenant, session, artifact.ID); body != files[path] { + t.Fatalf("Turn %d %s bytes = %q, want %q", turnNumber-1, path, body, files[path]) + } + } + return published + } + unchanged := func(artifacts ...SessionArtifact) { + t.Helper() + for _, artifact := range artifacts { + if got, err := s.GetSessionArtifact(t.Context(), tenant, session, artifact.ID); err != nil || got != artifact { + t.Fatalf("existing Artifact changed: %+v -> %+v %v", artifact, got, err) + } + } + } + objects := func() { + t.Helper() + var rows int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_artifacts WHERE session_id = $1", session).Scan(&rows); err != nil { + t.Fatal(err) + } + if count := sourceObjectCount(t, pool); count != before+rows { + t.Fatalf("unpublished captures kept private objects: %d objects for %d Artifacts", count-before, rows) + } + } + + // The first Turn behaves as before: every regular output is published. + outputs := map[string]string{"a.txt": "alpha", "sub/b.txt": "bravo", "empty.txt": ""} + one := run(outputs, "a.txt", "sub/b.txt", "empty.txt") + if err := s.DeleteSessionArtifact(t.Context(), tenant, session, one["a.txt"].ID); err != nil { + t.Fatal(err) + } + // New c.txt and deleted-then-unchanged a.txt; unchanged paths keep their IDs. + outputs["c.txt"] = "charlie" + two := run(outputs, "a.txt", "c.txt") + unchanged(one["sub/b.txt"], one["empty.txt"]) + objects() + // Changed bytes publish a new version and leave the earlier one intact. + outputs["sub/b.txt"] = "bravo-v2" + three := run(outputs, "sub/b.txt") + unchanged(one["sub/b.txt"], one["empty.txt"], two["a.txt"], two["c.txt"]) + if body := artifactBytes(t, s, tenant, session, one["sub/b.txt"].ID); body != "bravo" { + t.Fatalf("earlier version changed: %q", body) + } + // Comparison uses the newest version, not any earlier one with equal bytes. + outputs["sub/b.txt"] = "bravo" + four := run(outputs, "sub/b.txt") + // Entirely unchanged outputs, and a removed workspace file, publish nothing. + delete(outputs, "c.txt") + run(outputs) + unchanged(one["sub/b.txt"], one["empty.txt"], two["a.txt"], two["c.txt"], three["sub/b.txt"], four["sub/b.txt"]) + objects() + // Deletion leaves no tombstone: the newest remaining version is the comparison base. + if err := s.DeleteSessionArtifact(t.Context(), tenant, session, four["sub/b.txt"].ID); err != nil { + t.Fatal(err) + } + outputs["sub/b.txt"] = "bravo-v2" + run(outputs) + outputs["sub/b.txt"] = "bravo" + run(outputs, "sub/b.txt") + + // A deletion committed after private capture but before completion is seen + // by the completion transaction, so the same Turn republishes the path. + turn := startArtifactTurn(t, s, tenant, session, "artifact-turn-delete-during-capture") + stageArtifactOutputs(t, s, tenant, session, environment, turn, outputs) + if err := s.DeleteSessionArtifact(t.Context(), tenant, session, two["a.txt"].ID); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + if got := publishedPaths(publishedByTurn(t, s, tenant, session, turn)); !reflect.DeepEqual(got, []string{"a.txt"}) { + t.Fatalf("deletion during capture: published %v", got) + } + objects() + + // Another Session in the same tenant never compares against these Artifacts. + other, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact-other", "openai_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + otherEnvironment, err := s.GetSessionEnvironment(t.Context(), tenant, other.ID) + if err != nil { + t.Fatal(err) + } + otherTurn := startArtifactTurn(t, s, tenant, other.ID, "artifact-other-turn") + stageArtifactOutputs(t, s, tenant, other.ID, otherEnvironment.ID, otherTurn, outputs) + transition(t, s, tenant, other.ID, otherTurn, TurnInProgress, TurnCompleted) + if got := publishedPaths(publishedByTurn(t, s, tenant, other.ID, otherTurn)); !reflect.DeepEqual(got, []string{"a.txt", "empty.txt", "sub/b.txt"}) { + t.Fatalf("other Session first Turn published %v", got) + } + for _, id := range []string{session, other.ID} { + if err := s.DeleteSession(t.Context(), tenant, id); err != nil { + t.Fatal(err) + } + } + if count := sourceObjectCount(t, pool); count != before { + t.Fatalf("objects leaked: %d -> %d", before, count) + } +} + +// Publication time can come from the Runtime's reported completion and invert +// the order of Turns; the newest version for a path still follows Turn order. +func TestSessionArtifactsNewestVersionFollowsTurnOrder(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + created, err := s.CreateSession(t.Context(), tenant, environmentInput("artifact-order", "openai_hosted", "/workspace")) + if err != nil { + t.Fatal(err) + } + session := created.ID + env, err := s.GetSessionEnvironment(t.Context(), tenant, session) + if err != nil { + t.Fatal(err) + } + // Turn 1 reports a native completion one hour ahead, so its Artifact is + // published later than every following Turn's. + first := submitMessage(t, s, tenant, session, "artifact-order-1") + transition(t, s, tenant, session, first.TurnID, TurnQueued, TurnInProgress) + stageArtifactOutputs(t, s, tenant, session, env.ID, first.TurnID, map[string]string{"b.txt": "bravo"}) + future := time.Now().Add(time.Hour).UnixMilli() + if _, err := s.CompleteExecution(t.Context(), tenant, session, first.TurnID, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, future)), "", first.Sequence); err != nil { + t.Fatal(err) + } + one := publishedByTurn(t, s, tenant, session, first.TurnID)["b.txt"] + run := func(key, body string) map[string]SessionArtifact { + t.Helper() + turn := startArtifactTurn(t, s, tenant, session, key) + stageArtifactOutputs(t, s, tenant, session, env.ID, turn, map[string]string{"b.txt": body}) + transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + return publishedByTurn(t, s, tenant, session, turn) + } + two := run("artifact-order-2", "bravo-v2")["b.txt"] + if two.ID == "" || !two.CreatedAt.Before(one.CreatedAt) { + t.Fatalf("fixture did not invert publication time: %+v %+v", one, two) + } + // Turn 2's version is the newest although Turn 1 was published later. + if got := run("artifact-order-3", "bravo-v2"); len(got) != 0 { + t.Fatalf("unchanged bytes of the newest Turn republished: %+v", got) + } + if got := publishedPaths(run("artifact-order-4", "bravo")); strings.Join(got, ",") != "b.txt" { + t.Fatalf("bytes of an older Turn's version were not republished: %v", got) + } +} + +// A deletion that holds the Session lock while Turn completion waits for it is +// seen by the completion transaction, which then republishes the path. +func TestSessionArtifactsCompletionWaitsForConcurrentDeletion(t *testing.T) { + s, pool := testStore(t) + tenant, session, environment, first := artifactTurn(t, s, "openai_hosted") + before := sourceObjectCount(t, pool) + stageArtifactOutputs(t, s, tenant, session, environment, first, map[string]string{"a.txt": "alpha"}) + transition(t, s, tenant, session, first, TurnInProgress, TurnCompleted) + newest := publishedByTurn(t, s, tenant, session, first)["a.txt"] + turn := startArtifactTurn(t, s, tenant, session, "artifact-concurrent-delete") + stageArtifactOutputs(t, s, tenant, session, environment, turn, map[string]string{"a.txt": "alpha"}) + + // Delete exactly as DeleteSessionArtifact does, but keep the transaction open. + tx, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + lookup, err := artifactLookup(tenant, session, newest.ID) + if err != nil { + t.Fatal(err) + } + q := s.queries.WithTx(tx) + if _, err := q.LockSession(t.Context(), sqlc.LockSessionParams{TenantID: lookup.TenantID, ID: lookup.SessionID}); err != nil { + t.Fatal(err) + } + oid, err := q.DeleteSessionArtifact(t.Context(), sqlc.DeleteSessionArtifactParams(lookup)) + if err != nil { + t.Fatal(err) + } + objects := tx.LargeObjects() + if err := objects.Unlink(t.Context(), oid.Uint32); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := s.TransitionTurn(t.Context(), tenant, session, turn, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnCompleted}) + done <- err + }() + // Completion must be blocked on the Session lock before the deletion commits. + deadline := time.Now().Add(10 * time.Second) + for { + var waiting int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'").Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting > 0 { + break + } + select { + case err := <-done: + t.Fatalf("completion did not wait for the Session lock: %v", err) + default: + } + if time.Now().After(deadline) { + t.Fatal("completion never waited for the Session lock") + } + time.Sleep(10 * time.Millisecond) + } + if status, err := s.GetTurn(t.Context(), tenant, session, turn); err != nil || status.Status != TurnInProgress { + t.Fatalf("Turn settled while the deletion held the lock: %+v %v", status, err) + } + if err := tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + published := publishedByTurn(t, s, tenant, session, turn) + if got := publishedPaths(published); strings.Join(got, ",") != "a.txt" || artifactBytes(t, s, tenant, session, published["a.txt"].ID) != "alpha" { + t.Fatalf("concurrent deletion was not republished: %v", got) + } + if _, err := s.GetSessionArtifact(t.Context(), tenant, session, newest.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted Artifact remains: %v", err) + } + if count := sourceObjectCount(t, pool); count != before+1 { + t.Fatalf("private objects: %d -> %d, want one published Artifact", before, count) + } +} diff --git a/services/agents-api/internal/store/session_configuration_test.go b/services/core/internal/store/session_configuration_test.go similarity index 100% rename from services/agents-api/internal/store/session_configuration_test.go rename to services/core/internal/store/session_configuration_test.go diff --git a/services/core/internal/store/session_creation_identity.go b/services/core/internal/store/session_creation_identity.go new file mode 100644 index 000000000..34bd6ccc6 --- /dev/null +++ b/services/core/internal/store/session_creation_identity.go @@ -0,0 +1,158 @@ +package store + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// modelProviderKeyPurpose separates model provider key fingerprints from any +// other use of the credential key. +const modelProviderKeyPurpose = "parsar.agents-api.model-provider-api-key.v1" + +// fingerprintedProvider replaces a bundle's key with its keyed fingerprint, so +// idempotency hashes tell keys apart without ever hashing a key itself. +func (s *Store) fingerprintedProvider(provider *v1.ModelProviderInput) (*v1.ModelProviderInput, error) { + if provider == nil { + return nil, nil + } + fingerprint, err := s.credentialCipher.Fingerprint(modelProviderKeyPurpose, provider.APIKey) + if err != nil { + return nil, ErrCredentialStorageUnavailable + } + copy := *provider + copy.APIKey = "fingerprint:" + fingerprint + return ©, nil +} + +// withoutProviderKey replaces a caller intent's x_agents_core.model_provider +// with its typed value carrying a keyed fingerprint instead of the key. It fails +// closed: an intent whose extension or provider cannot be read is rejected +// rather than hashed as raw bytes that could carry a key. +func (s *Store) withoutProviderKey(raw json.RawMessage) (json.RawMessage, error) { + var request map[string]json.RawMessage + if json.Unmarshal(raw, &request) != nil || request == nil { + return nil, ErrInvalidInput + } + extensionRaw, present := request["x_agents_core"] + if !present || jsonNull(extensionRaw) { + return raw, nil + } + var extension map[string]json.RawMessage + if json.Unmarshal(extensionRaw, &extension) != nil || extension == nil { + return nil, ErrInvalidInput + } + providerRaw, present := extension["model_provider"] + if !present || jsonNull(providerRaw) { + return raw, nil + } + var provider v1.ModelProviderInput + if json.Unmarshal(providerRaw, &provider) != nil { + return nil, ErrInvalidInput + } + fingerprinted, err := s.fingerprintedProvider(&provider) + if err != nil { + return nil, err + } + if extension["model_provider"], err = json.Marshal(fingerprinted); err != nil { + return nil, err + } + if request["x_agents_core"], err = json.Marshal(extension); err != nil { + return nil, err + } + return json.Marshal(request) +} + +func jsonNull(raw json.RawMessage) bool { + return string(bytes.TrimSpace(raw)) == "null" +} + +func (s *Store) creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { + if len(raw) == 0 { + return pgtype.Text{}, nil + } + if len(raw) > 16<<20 { + return pgtype.Text{}, ErrInvalidInput + } + raw, err := s.withoutProviderKey(raw) + if err != nil { + return pgtype.Text{}, err + } + canonical, err := canonicalJSONObject(raw) + if err != nil { + return pgtype.Text{}, err + } + hash := sha256.Sum256(canonical) + return pgtype.Text{String: hex.EncodeToString(hash[:]), Valid: true}, nil +} + +// FindSessionCreation recovers recorded caller intent without resolving a mutable source. +func (s *Store) FindSessionCreation(ctx context.Context, tenantID, key string, request json.RawMessage, creator identity.Subject) (SessionCreation, error) { + if err := creator.Validate(); err != nil { + return SessionCreation{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + tenant, err := parseID(tenantID) + if err != nil { + return SessionCreation{}, err + } + if strings.TrimSpace(key) == "" || len(key) > 128 { + return SessionCreation{}, ErrInvalidInput + } + hash, err := s.creationRequestHash(request) + if errors.Is(err, ErrCredentialStorageUnavailable) { + // Without the credential key no Session with a provider bundle can have + // been committed or can be created; creation reports the missing key + // after request validation. + return SessionCreation{}, ErrNotFound + } + if err != nil { + return SessionCreation{}, err + } + if !hash.Valid { + return SessionCreation{}, ErrInvalidInput + } + var row sqlc.Session + var environment *Environment + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + row, err = q.FindSessionCreation(ctx, sqlc.FindSessionCreationParams{TenantID: tenant, IdempotencyKey: key}) + if err != nil { + return err + } + environment, err = sessionEnvironmentSnapshot(ctx, q, row) + return err + }) + if errors.Is(err, pgx.ErrNoRows) { + return SessionCreation{}, ErrNotFound + } + if err != nil { + return SessionCreation{}, fmt.Errorf("find session creation: %w", err) + } + if row.DeletedAt.Valid || !row.CreatorKind.Valid || !row.CreatorID.Valid || row.CreatorKind.String != creator.Kind || row.CreatorID.String != creator.ID { + return SessionCreation{}, ErrIdempotencyConflict + } + // Missing request intent does not imply missing ownership. Known creators may + // still fall back to the original resolved-request equivalence at the upsert. + if !row.CreationRequestHash.Valid { + return SessionCreation{}, ErrNotFound + } + if row.CreationRequestHash.String != hash.String { + return SessionCreation{}, ErrIdempotencyConflict + } + session, err := sessionFromRow(row) + session.Environment = environment + // The row and cursor share one committed snapshot; later events remain observable. + return SessionCreation{Session: session, Cursor: row.EventSequence}, err +} diff --git a/services/agents-api/internal/store/session_creation_identity_test.go b/services/core/internal/store/session_creation_identity_test.go similarity index 100% rename from services/agents-api/internal/store/session_creation_identity_test.go rename to services/core/internal/store/session_creation_identity_test.go diff --git a/services/agents-api/internal/store/session_creation_stream.go b/services/core/internal/store/session_creation_stream.go similarity index 100% rename from services/agents-api/internal/store/session_creation_stream.go rename to services/core/internal/store/session_creation_stream.go diff --git a/services/agents-api/internal/store/session_creation_stream_test.go b/services/core/internal/store/session_creation_stream_test.go similarity index 100% rename from services/agents-api/internal/store/session_creation_stream_test.go rename to services/core/internal/store/session_creation_stream_test.go diff --git a/services/agents-api/internal/store/session_creator.go b/services/core/internal/store/session_creator.go similarity index 84% rename from services/agents-api/internal/store/session_creator.go rename to services/core/internal/store/session_creator.go index bb9eaa6d9..8b2f8f78b 100644 --- a/services/agents-api/internal/store/session_creator.go +++ b/services/core/internal/store/session_creator.go @@ -3,7 +3,7 @@ package store import ( "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/session_creator_test.go b/services/core/internal/store/session_creator_test.go similarity index 98% rename from services/agents-api/internal/store/session_creator_test.go rename to services/core/internal/store/session_creator_test.go index 6ed0d6e84..a74b0618c 100644 --- a/services/agents-api/internal/store/session_creator_test.go +++ b/services/core/internal/store/session_creator_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/google/uuid" ) diff --git a/services/core/internal/store/session_deletion.go b/services/core/internal/store/session_deletion.go new file mode 100644 index 000000000..3d69804be --- /dev/null +++ b/services/core/internal/store/session_deletion.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// ErrSessionNotIdle rejects deletion of a Session that still has work or input +// pending. Callers cancel first and delete after the Session settles. +var ErrSessionNotIdle = errors.New("session must be durably idle or failed without required actions before deletion") + +// DeleteSession removes public access to a durably idle or failed Session while +// retaining state needed to settle execution. The decision is taken under the +// Session lock that also orders Turn and input admission, so a concurrent +// admission either commits first and is rejected here, or observes the deletion. +// The owner's repeated deletion succeeds without another resource write; foreign and +// missing Sessions remain not found. +func (s *Store) DeleteSession(ctx context.Context, tenantID, sessionID string) error { + return s.withLockedSession(ctx, tenantID, sessionID, true, func(ctx context.Context, q *sqlc.Queries, session sqlc.LockSessionRow) error { + audit := func() error { + return recordWriteAudit(ctx, q, tenantID, "delete", "session", uuid.UUID(session.ID.Bytes).String(), "") + } + if session.DeletedAt.Valid { + return audit() + } + if err := requireSessionSettled(ctx, q, session.ID); err != nil { + return err + } + if err := q.DeleteSessionArtifacts(ctx, session.ID); err != nil { + return err + } + if err := q.ReleaseUnallocatedRuntimePlacement(ctx, session.ID); err != nil { + return err + } + if err := q.MarkSessionDeleted(ctx, session.ID); err != nil { + return err + } + return audit() + }) +} + +// requireSessionSettled rejects a queued, in-progress or waiting Turn, which +// includes pending required actions and function results, and a pending input +// reservation: queued later input, self-hosted input awaiting a connection, or +// hosted initial input while provisioning. Terminal idle and failed Sessions pass. +func requireSessionSettled(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + if _, err := q.GetActiveTurn(ctx, session); err == nil { + return ErrSessionNotIdle + } else if !errors.Is(err, pgx.ErrNoRows) { + return err + } + _, pending, err := environmentInputState(ctx, q, session) + if err != nil { + return err + } + if pending { + return ErrSessionNotIdle + } + return nil +} + +// cancelSessionWork requests cancellation of active work for Runtime cleanup. +func cancelSessionWork(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + turn, err := q.GetActiveTurn(ctx, session) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + if err := requestTurnCancel(ctx, q, session, turn); err != nil { + return err + } + } + if err := q.CancelSessionEnvironmentInput(ctx, session); err != nil { + return err + } + return nil +} + +func requestTurnCancel(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn sqlc.Turn) error { + if err := q.RequestTurnCancel(ctx, sqlc.RequestTurnCancelParams{ID: turn.ID, SessionID: session}); err != nil { + return err + } + if turn.Status == TurnQueued { + cancelled, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) + if err != nil { + return err + } + if err := recordTurnChange(ctx, q, cancelled, false); err != nil { + return err + } + } else if turn.Status == TurnWaiting && !turn.CancelRequestedAt.Valid { + cancelling, err := q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: session, ID: turn.ID}) + if err != nil { + return err + } + if err := recordSessionActivity(ctx, q, cancelling, nil); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/store/session_deletion_execution_test.go b/services/core/internal/store/session_deletion_execution_test.go similarity index 97% rename from services/agents-api/internal/store/session_deletion_execution_test.go rename to services/core/internal/store/session_deletion_execution_test.go index d33489c8a..13c1dcfec 100644 --- a/services/agents-api/internal/store/session_deletion_execution_test.go +++ b/services/core/internal/store/session_deletion_execution_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { diff --git a/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go b/services/core/internal/store/session_deletion_lifecycle_public_test.go similarity index 98% rename from services/agents-api/internal/store/session_deletion_lifecycle_public_test.go rename to services/core/internal/store/session_deletion_lifecycle_public_test.go index 8cc38e269..b0db485f6 100644 --- a/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go +++ b/services/core/internal/store/session_deletion_lifecycle_public_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_deletion_public_test.go b/services/core/internal/store/session_deletion_public_test.go similarity index 88% rename from services/agents-api/internal/store/session_deletion_public_test.go rename to services/core/internal/store/session_deletion_public_test.go index 4795eb2f9..e16cdde0d 100644 --- a/services/agents-api/internal/store/session_deletion_public_test.go +++ b/services/core/internal/store/session_deletion_public_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_deletion_test.go b/services/core/internal/store/session_deletion_test.go similarity index 99% rename from services/agents-api/internal/store/session_deletion_test.go rename to services/core/internal/store/session_deletion_test.go index 0f9ee0fb2..5f5bd5918 100644 --- a/services/agents-api/internal/store/session_deletion_test.go +++ b/services/core/internal/store/session_deletion_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/core/internal/store/session_diagnostics.go b/services/core/internal/store/session_diagnostics.go new file mode 100644 index 000000000..78738ae4e --- /dev/null +++ b/services/core/internal/store/session_diagnostics.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ItemDiagnosticTiming records Core database receipt and settlement, never native +// execution duration. Historical terminal Items can have unknown settlement. +type ItemDiagnosticTiming struct { + ItemID string + StartedAt time.Time + CompletedAt *time.Time +} + +type TurnDiagnosticsSnapshot struct { + Session Session + Turn Turn + Items []ItemDiagnosticTiming + ItemsTruncated bool +} + +// GetSessionDiagnosticsSnapshot keeps all existing Session projections in one +// read-only snapshot, including the failure precedence used by the public API. +func (s *Store) GetSessionDiagnosticsSnapshot(ctx context.Context, tenantID, sessionID string) (Session, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Session{}, err + } + var session Session + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: tenant, ID: parsePathID(sessionID)}) + if err != nil { + return err + } + session, err = sessionFromRow(row) + if err != nil { + return err + } + session, err = readSessionActivity(ctx, q, session) + return err + }) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + return session, err +} + +// GetTurnDiagnosticsSnapshot reads only root Turns and their root Items. Its +// bounded query and public projection share the same committed snapshot. +func (s *Store) GetTurnDiagnosticsSnapshot(ctx context.Context, tenantID, sessionID, turnID string) (TurnDiagnosticsSnapshot, error) { + params, err := publicTurnLookup(tenantID, sessionID, turnID) + if err != nil { + return TurnDiagnosticsSnapshot{}, err + } + result := TurnDiagnosticsSnapshot{Items: []ItemDiagnosticTiming{}} + err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + turn, err := q.GetTurn(ctx, params) + if err != nil { + return err + } + row, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: params.TenantID, ID: params.SessionID}) + if err != nil { + return err + } + result.Session, err = sessionFromRow(row) + if err != nil { + return err + } + result.Turn = turnFromRow(turn) + rows, err := q.ListTurnItemDiagnostics(ctx, sqlc.ListTurnItemDiagnosticsParams{SessionID: params.SessionID, TurnID: params.ID}) + if err != nil { + return err + } + result.ItemsTruncated = len(rows) > 1000 + if result.ItemsTruncated { + rows = rows[:1000] + } + for _, row := range rows { + item := ItemDiagnosticTiming{ItemID: uuid.UUID(row.ID.Bytes).String(), StartedAt: row.CreatedAt.Time} + if row.SettledAt.Valid { + at := row.SettledAt.Time + item.CompletedAt = &at + } + result.Items = append(result.Items, item) + } + return nil + }) + if errors.Is(err, pgx.ErrNoRows) { + return TurnDiagnosticsSnapshot{}, ErrNotFound + } + return result, err +} diff --git a/services/agents-api/internal/store/session_diagnostics_cancellation_test.go b/services/core/internal/store/session_diagnostics_cancellation_test.go similarity index 100% rename from services/agents-api/internal/store/session_diagnostics_cancellation_test.go rename to services/core/internal/store/session_diagnostics_cancellation_test.go diff --git a/services/core/internal/store/session_diagnostics_test.go b/services/core/internal/store/session_diagnostics_test.go new file mode 100644 index 000000000..95824720a --- /dev/null +++ b/services/core/internal/store/session_diagnostics_test.go @@ -0,0 +1,368 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func diagnosticToolEvent(id, stage, status string) ExecutionEvent { + return ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(fmt.Sprintf(`{"id":%q,"stage":%q,"observation":{"kind":"command","command":"private-command-canary","status":%q}}`, id, stage, status))} +} + +func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + before := diagnosticToolEvent("cmd", "before", "in_progress") + after := diagnosticToolEvent("cmd", "after", "failed") + for i, event := range []ExecutionEvent{before, after} { + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, int32(i+1), []ExecutionEvent{event}); err != nil { + t.Fatal(err) + } + } + snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || len(snap.Items) != 2 { + t.Fatal(snap, err) + } + if snap.Items[0].CompletedAt == nil || !snap.Items[0].CompletedAt.Equal(snap.Items[0].StartedAt) { + t.Fatal("terminal input did not settle at its receipt", snap.Items[0]) + } + item := snap.Items[1] + var received time.Time + if err := s.pool.QueryRow(t.Context(), "SELECT created_at FROM turn_events WHERE turn_id=$1 AND ordinal=2", receipt.TurnID).Scan(&received); err != nil { + t.Fatal(err) + } + if item.CompletedAt == nil || !item.CompletedAt.Equal(received) || item.CompletedAt.Before(item.StartedAt) { + t.Fatal("Item did not use terminal receipt", item, received) + } + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 2, []ExecutionEvent{after}); err != nil { + t.Fatal(err) + } + // A terminal legacy Item with unknown settlement must remain unknown even on a repeated upsert. + runtimeSuspensionSQL(t, s.pool, "UPDATE session_items SET settled_at=NULL WHERE id=$1", item.ItemID) + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, []ExecutionEvent{after}); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) + snap, err = s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || snap.Items[1].CompletedAt != nil { + t.Fatal("historical settlement synthesized", snap, err) + } +} + +func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { + for _, skew := range []time.Duration{-269 * time.Second, 269 * time.Second} { + t.Run(skew.String(), func(t *testing.T) { + s, w, owner := managedIdleClockFixture(t) + turn := uuid.NewString() + runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at) VALUES($1,$2,'in_progress',clock_timestamp())", turn, owner.SessionID) + events := []ExecutionEvent{diagnosticToolEvent("first", "before", "in_progress"), diagnosticToolEvent("second", "before", "in_progress")} + if err := w.AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { + t.Fatal(err) + } + source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() + before := runtimeDatabaseTime(t, s) + completed, err := w.CompleteExecution(t.Context(), owner.TenantID, owner.SessionID, turn, TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) + after := runtimeDatabaseTime(t, s) + if err != nil || completed.CompletedAt.UnixMilli() != source { + t.Fatal("public native completion changed", completed, err) + } + snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn) + if err != nil || len(snap.Items) != 2 { + t.Fatal(snap, err) + } + for _, item := range snap.Items { + if item.CompletedAt == nil || item.CompletedAt.Before(before) || item.CompletedAt.After(after) || item.CompletedAt.Before(item.StartedAt) { + t.Fatal("force settlement used remote or transaction-start clock", item) + } + } + if !snap.Items[0].CompletedAt.Equal(*snap.Items[1].CompletedAt) { + t.Fatal("force settlement has multiple clocks") + } + }) + } +} + +func TestDiagnosticSettlementWaitsForSessionLock(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + tx, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err = tx.Exec(t.Context(), "SELECT id FROM sessions WHERE id=$1 FOR UPDATE", session.ID); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := s.TransitionTurn(t.Context(), tenant, session.ID, receipt.TurnID, TurnTransition{ExpectedStatus: TurnInProgress, Status: TurnFailed}) + done <- err + }() + // Wait for the actual competing transaction to block, not a scheduler delay. + deadline := time.After(5 * time.Second) + for { + var waiting bool + err = pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%sessions%')").Scan(&waiting) + if err != nil { + t.Fatal(err) + } + if waiting { + break + } + select { + case err := <-done: + t.Fatal("terminal transaction did not wait", err) + case <-deadline: + t.Fatal("terminal transaction never reached lock") + case <-time.After(10 * time.Millisecond): + } + } + itemID := uuid.NewString() + _, err = tx.Exec(t.Context(), "INSERT INTO session_items(id,session_id,turn_id,created_at,position,payload) VALUES($1,$2,$3,clock_timestamp(),1,$4)", itemID, session.ID, receipt.TurnID, `{"id":"`+itemID+`","turn_id":"`+receipt.TurnID+`","type":"command_execution","status":"in_progress","command":"run"}`) + if err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if err = <-done; err != nil { + t.Fatal(err) + } + snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil { + t.Fatal(err) + } + item := snap.Items[len(snap.Items)-1] + if item.CompletedAt == nil || item.CompletedAt.Before(item.StartedAt) { + t.Fatal("transaction-start settlement predates serialized Item receipt", item) + } +} + +func TestDiagnosticTimingBoundOrderAndIsolation(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + // Insert in reverse position order with equal times; response must follow public Item ordering. + _, err := pool.Exec(t.Context(), `INSERT INTO session_items(id,session_id,turn_id,created_at,position,payload) + SELECT gen_random_uuid(),$1,$2,'2020-01-01T00:00:00Z'::timestamptz,n,'{"status":"completed"}'::jsonb FROM generate_series(1000,1,-1) n`, session.ID, receipt.TurnID) + if err != nil { + t.Fatal(err) + } + snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || len(snap.Items) != 1000 || !snap.ItemsTruncated { + t.Fatal("unbounded diagnostics", len(snap.Items), snap.ItemsTruncated, err) + } + for i, item := range snap.Items { + var position int + if err := pool.QueryRow(t.Context(), "SELECT position FROM session_items WHERE id=$1", item.ItemID).Scan(&position); err != nil { + t.Fatal(err) + } + if position != i+1 || item.CompletedAt != nil { + t.Fatal("order or historical settlement changed", position, item) + } + } + runtimeSuspensionSQL(t, pool, "DELETE FROM session_items WHERE turn_id=$1 AND created_at>'2020-01-01T00:00:00Z'", receipt.TurnID) + exact, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || len(exact.Items) != 1000 || exact.ItemsTruncated { + t.Fatal("exact limit falsely truncated", len(exact.Items), exact.ItemsTruncated, err) + } + for _, ids := range [][3]string{{uuid.NewString(), session.ID, receipt.TurnID}, {tenant, "malformed", receipt.TurnID}, {tenant, session.ID, uuid.NewString()}} { + if _, err := s.GetTurnDiagnosticsSnapshot(t.Context(), ids[0], ids[1], ids[2]); !errors.Is(err, ErrNotFound) { + t.Fatal("scope leaked", ids, err) + } + } + runtimeSuspensionSQL(t, pool, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID) + if _, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted root visible", err) + } + if _, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session visible", err) + } +} + +func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + input := environmentInput("safe-detail", "openai_hosted", "/workspace") + input.InitialInputs = []Input{messageInput("initial")} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + writer := executionLease(t, s).Store() + owner, err := writer.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + constraint := "diagnostics_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, err = pool.Exec(t.Context(), "ALTER TABLE session_events ADD CONSTRAINT "+constraint+" CHECK (session_id <> '"+session.ID+"' OR payload->'event'->>'type' <> 'agent.session.failed') NOT VALID") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) + }) + failure := ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 2, ExitCode: 7} + runtimeSuspensionSQL(t, pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID) + preparation := EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} + if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err == nil { + t.Fatal("failure committed without events") + } + var detail []byte + if err = pool.QueryRow(t.Context(), "SELECT failure_detail FROM environments WHERE id=$1", owner.EnvironmentID).Scan(&detail); err != nil || detail != nil { + t.Fatal("partial failure detail", string(detail), err) + } + runtimeSuspensionSQL(t, pool, "ALTER TABLE session_events DROP CONSTRAINT "+constraint) + if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err != nil { + t.Fatal(err) + } + snap, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) + if err != nil || snap.EnvironmentFailure == nil || snap.EnvironmentFailure.Detail == nil || *snap.EnvironmentFailure.Detail.Index != 2 || *snap.EnvironmentFailure.Detail.ExitCode != 7 { + t.Fatal("detail not persisted", snap.EnvironmentFailure, err) + } + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(events) + if strings.Contains(string(raw), "exit_code") || strings.Contains(string(raw), "failure_detail") { + t.Fatal("private fields entered SSE", string(raw)) + } + // Historical reasons are never parsed into structured detail; corrupted private fields are sanitized. + runtimeSuspensionSQL(t, pool, "UPDATE environments SET failure_detail=$2 WHERE id=$1", owner.EnvironmentID, `{"step":"secret-provider-token","index":3,"exit_code":2}`) + snap, err = s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) + if err != nil || snap.EnvironmentFailure.Detail != nil { + t.Fatal("unsafe private detail projected", snap.EnvironmentFailure, err) + } +} + +func TestDiagnosticPutItemRollsBack(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + tx, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + id, _ := parseID(uuid.NewString()) + sid, _ := parseID(session.ID) + tid, _ := parseID(receipt.TurnID) + _, err = sqlc.New(tx).PutSessionItem(t.Context(), sqlc.PutSessionItemParams{ID: id, SessionID: sid, TurnID: tid, CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, Payload: []byte(`{"status":"completed"}`)}) + if err != nil { + t.Fatal(err) + } + if err = tx.Rollback(t.Context()); err != nil { + t.Fatal(err) + } + snap, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || len(snap.Items) != 1 { + t.Fatal("rolled back receipt visible", snap, err) + } +} + +func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + transition(t, s, tenant, session.ID, receipt.TurnID, TurnQueued, TurnInProgress) + events := []ExecutionEvent{diagnosticToolEvent("cmd", "before", "in_progress"), diagnosticToolEvent("cmd", "after", "completed")} + if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 1, events); err != nil { + t.Fatal(err) + } + first, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil { + t.Fatal(err) + } + item := first.Items[1] + if item.CompletedAt == nil { + t.Fatal("missing first settlement") + } + // Exercise persistence defensively even if a producer bypasses the normal + // Item merge's terminal-status preservation. + for _, force := range []bool{false, true} { + runtimeSuspensionSQL(t, pool, "UPDATE session_items SET payload=jsonb_set(payload,'{status}','\"in_progress\"') WHERE id=$1", item.ItemID) + if force { + transition(t, s, tenant, session.ID, receipt.TurnID, TurnInProgress, TurnFailed) + } else if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, events[1:]); err != nil { + t.Fatal(err) + } + got, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) + if err != nil || got.Items[1].CompletedAt == nil || !got.Items[1].CompletedAt.Equal(*item.CompletedAt) { + t.Fatal("first settlement overwritten", force, got, err) + } + } +} + +func TestDiagnosticRootReadRejectsActualChildTurn(t *testing.T) { + s, w, owner := managedIdleClockFixture(t) + root := runtimeSuspensionCompleted(t, s.pool, owner) + child, turn := uuid.NewString(), uuid.NewString() + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn','in_progress',clock_timestamp())`, turn, owner.SessionID, child) + if _, err := w.GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn); !errors.Is(err, ErrNotFound) { + t.Fatal("child Turn became root diagnostics", err) + } +} + +func TestDiagnosticSessionSnapshotConcurrentCommitConsistency(t *testing.T) { + s, pool := testStore(t) + tenant, session := newTurnSession(t, s) + receipt := submitMessage(t, s, tenant, session.ID, "start") + runtimeSuspensionSQL(t, pool, `UPDATE sessions SET metadata='{"generation":"0"}' WHERE id=$1`, session.ID) + runtimeSuspensionSQL(t, pool, `UPDATE turns SET outcome='{"generation":"0"}' WHERE id=$1`, receipt.TurnID) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + for i := 1; i <= 150; i++ { + tx, err := pool.Begin(ctx) + if err != nil { + done <- err + return + } + raw := fmt.Sprintf(`{"generation":"%d"}`, i) + _, err = tx.Exec(ctx, "UPDATE sessions SET metadata=$2 WHERE id=$1", session.ID, raw) + if err == nil { + _, err = tx.Exec(ctx, "UPDATE turns SET outcome=$2 WHERE id=$1", receipt.TurnID, raw) + } + if err != nil { + _ = tx.Rollback(context.Background()) + done <- err + return + } + if err = tx.Commit(ctx); err != nil { + done <- err + return + } + } + done <- nil + }() + for i := 0; i < 150; i++ { + snapshot, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + var outcome map[string]string + if snapshot.LastTurn == nil || json.Unmarshal(snapshot.LastTurn.Outcome, &outcome) != nil || outcome["generation"] != snapshot.Metadata["generation"] { + t.Fatal("mixed committed snapshots", snapshot.Metadata, outcome) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/session_environment_snapshot.go b/services/core/internal/store/session_environment_snapshot.go similarity index 87% rename from services/agents-api/internal/store/session_environment_snapshot.go rename to services/core/internal/store/session_environment_snapshot.go index 011e520eb..43f42ad92 100644 --- a/services/agents-api/internal/store/session_environment_snapshot.go +++ b/services/core/internal/store/session_environment_snapshot.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/session_environment_snapshot_test.go b/services/core/internal/store/session_environment_snapshot_test.go similarity index 100% rename from services/agents-api/internal/store/session_environment_snapshot_test.go rename to services/core/internal/store/session_environment_snapshot_test.go diff --git a/services/agents-api/internal/store/session_events.go b/services/core/internal/store/session_events.go similarity index 97% rename from services/agents-api/internal/store/session_events.go rename to services/core/internal/store/session_events.go index e657d2f21..a4c361afd 100644 --- a/services/agents-api/internal/store/session_events.go +++ b/services/core/internal/store/session_events.go @@ -7,8 +7,8 @@ import ( "errors" "sort" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/session_events_test.go b/services/core/internal/store/session_events_test.go similarity index 98% rename from services/agents-api/internal/store/session_events_test.go rename to services/core/internal/store/session_events_test.go index 84405368c..6170bb2be 100644 --- a/services/agents-api/internal/store/session_events_test.go +++ b/services/core/internal/store/session_events_test.go @@ -7,8 +7,8 @@ import ( "slices" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/core/internal/store/session_execution_configuration.go b/services/core/internal/store/session_execution_configuration.go new file mode 100644 index 000000000..7d0b21567 --- /dev/null +++ b/services/core/internal/store/session_execution_configuration.go @@ -0,0 +1,136 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// The projection is creation metadata, excluded from retry identity. Only the +// transaction that inserts the Session writes it; retries cannot repair or alter it. +func saveSessionExecutionConfiguration(ctx context.Context, q *sqlc.Queries, session sqlc.Session, projection *v1.SessionExecutionConfiguration, provider *v1.ModelProviderInput, providerSource string, revision uuid.UUID) error { + if projection == nil { + return nil + } + var frozenRevision pgtype.UUID + frozen := *projection + model, err := sessionExecutionModel(session.Configuration) + if err != nil { + return err + } + if !sameExecutionValue(frozen.Model.Value, model) || frozen.Harness.Value == nil || *frozen.Harness.Value != session.Engine || + !validExecutionSource(frozen.Model.Source) || !validExecutionSource(frozen.Harness.Source) { + return fmt.Errorf("%w: execution projection does not match Session configuration", ErrInvalidInput) + } + switch frozen.ModelProvider.Source { + case "deployment": + if provider == nil { + return fmt.Errorf("%w: execution projection has no model provider", ErrInvalidInput) + } + // The deployment default is readable with the same Core key, so the + // safe view is recorded from the frozen bundle itself. Native options + // are never part of it. + frozen.ModelProvider.Status = "available" + frozen.ModelProvider.Configuration = provider.SafeView() + if providerSource == v1.ModelProviderSourceDeployment && revision != uuid.Nil { + frozenRevision = pgtype.UUID{Bytes: revision, Valid: true} + } + case "session", "agent": + if provider == nil || frozen.ModelProvider.Status != "available" || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { + return fmt.Errorf("%w: execution projection does not match model provider", ErrInvalidInput) + } + case "unknown": + frozen.ModelProvider.Status = "unavailable" + frozen.ModelProvider.Configuration = nil + default: + return fmt.Errorf("%w: invalid execution projection source", ErrInvalidInput) + } + normalizeExecutionProjection(&frozen, uuid.UUID(session.ID.Bytes).String()) + raw, err := json.Marshal(frozen) + if err != nil { + return err + } + return q.SaveSessionExecutionConfiguration(ctx, sqlc.SaveSessionExecutionConfigurationParams{SessionID: session.ID, Configuration: raw, DeploymentProviderRevision: frozenRevision}) +} + +// GetSessionExecutionConfiguration reads only safe committed configuration. It +// never loads provider ciphertext, current defaults or runtime health. +func (s *Store) GetSessionExecutionConfiguration(ctx context.Context, tenantID, sessionID string) (v1.SessionExecutionConfiguration, error) { + tenant, err := parseID(tenantID) + if err != nil { + return v1.SessionExecutionConfiguration{}, err + } + row, err := s.queries.GetSessionExecutionConfiguration(ctx, sqlc.GetSessionExecutionConfigurationParams{TenantID: tenant, SessionID: parsePathID(sessionID)}) + if errors.Is(err, pgx.ErrNoRows) { + return v1.SessionExecutionConfiguration{}, ErrNotFound + } + if err != nil { + return v1.SessionExecutionConfiguration{}, fmt.Errorf("get session execution configuration: %w", err) + } + var projection v1.SessionExecutionConfiguration + if len(row.ExecutionConfiguration) == 0 { + model, err := sessionExecutionModel(row.SessionConfiguration) + if err != nil { + return projection, err + } + var harness *string + if row.Engine != "" { + harness = &row.Engine + } + projection.Model = v1.ExecutionSelection{Value: model, Source: "unknown"} + projection.Harness = v1.ExecutionSelection{Value: harness, Source: "unknown"} + projection.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + } else if err := json.Unmarshal(row.ExecutionConfiguration, &projection); err != nil { + return v1.SessionExecutionConfiguration{}, errors.New("invalid stored session execution configuration") + } + normalizeExecutionProjection(&projection, uuid.UUID(row.ID.Bytes).String()) + return projection, nil +} + +func normalizeExecutionProjection(projection *v1.SessionExecutionConfiguration, sessionID string) { + projection.Object = "agent.session.execution_configuration" + projection.SchemaVersion = 1 + if projection.HarnessConfig.Source == "" { + projection.HarnessConfig.Source = "unknown" + } + projection.HarnessConfig.Value = v1.ResolvedHarnessConfig(projection.HarnessConfig.Value) + projection.SessionID = sessionID + if projection.ModelProvider.Source == "deployment" && (projection.ModelProvider.Status != "available" || projection.ModelProvider.Configuration == nil) { + // Sessions created before deployment defaults moved into Core stay redacted. + projection.ModelProvider.Status = "redacted" + projection.ModelProvider.Configuration = nil + } else if projection.ModelProvider.Status != "available" { + projection.ModelProvider.Configuration = nil + } +} + +func sessionExecutionModel(configuration []byte) (*string, error) { + var config struct { + Agent struct { + Model *string `json:"model"` + } `json:"agent"` + } + if err := json.Unmarshal(configuration, &config); err != nil { + return nil, errors.New("invalid stored Session model configuration") + } + return config.Agent.Model, nil +} + +func sameExecutionValue(a, b *string) bool { + return a == nil && b == nil || a != nil && b != nil && *a == *b +} + +func validExecutionSource(source string) bool { + switch source { + case "session", "agent", "deployment", "unknown": + return true + } + return false +} diff --git a/services/core/internal/store/session_execution_configuration_test.go b/services/core/internal/store/session_execution_configuration_test.go new file mode 100644 index 000000000..6ab3d8c43 --- /dev/null +++ b/services/core/internal/store/session_execution_configuration_test.go @@ -0,0 +1,294 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func executionProjectionInput(source string) CreateSessionInput { + model, harness := "frozen-model", "codex" + return CreateSessionInput{ + Creator: FixtureCreator(), Engine: harness, IdempotencyKey: uuid.NewString(), + Configuration: []byte(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`), + ExecutionConfiguration: &v1.SessionExecutionConfiguration{ + Model: v1.ExecutionSelection{Value: &model, Source: source}, + Harness: v1.ExecutionSelection{Value: &harness, Source: source}, + ModelProvider: v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"}, + }, + } +} + +func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + for _, stream := range []bool{false, true} { + for _, source := range []string{"session", "agent", "deployment"} { + t.Run(source+map[bool]string{false: "/ordinary", true: "/stream"}[stream], func(t *testing.T) { + tenant := uuid.NewString() + input := executionProjectionInput(source) + input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://private-deployment.example/v1", APIKey: "private-projection-key-canary"} + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration.Object = "untrusted-object" + input.ExecutionConfiguration.SchemaVersion = 99 + input.ExecutionConfiguration.SessionID = "untrusted-session" + var session Session + var err error + if stream { + created, e := s.CreateSessionStream(t.Context(), tenant, input) + session, err = created.Session, e + } else { + session, err = s.CreateSession(t.Context(), tenant, input) + } + if err != nil { + t.Fatal(err) + } + // A reader without the encryption key can use the safe snapshot after restart. + reader := New(pool) + frozen, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != source || frozen.Harness.Source != source { + t.Fatal("incorrect frozen projection identity or provenance") + } + // Deployment defaults are readable with the same Core key, so every + // source records the safe view of the frozen bundle. + if frozen.ModelProvider.Status != "available" || !reflect.DeepEqual(frozen.ModelProvider.Configuration, input.ModelProvider.SafeView()) { + t.Fatal("safe provider projection changed") + } + var stored []byte + if err := pool.QueryRow(t.Context(), "SELECT configuration FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private-projection-key-canary", "native_options"} { + if bytes.Contains(stored, []byte(secret)) { + t.Fatal("secret entered safe projection") + } + } + // Source-only changes and invalid replacement metadata never alter the + // existing Session's retry identity or projection. + input.ExecutionConfiguration.Model.Source = "different-on-retry" + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + replay, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || replay.ID != session.ID { + t.Fatal("projection metadata changed retry identity", err) + } + if _, err := s.UpdateSessionMetadata(t.Context(), tenant, session.ID, map[string]string{"edited": "yes"}); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE session_model_execution SET encrypted_config='\\x00'::bytea WHERE session_id=$1", session.ID); err != nil { + t.Fatal(err) + } + got, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatal("snapshot changed or reader decrypted a secret", err) + } + if source == "deployment" { + // Sessions frozen before deployment defaults moved into Core stay redacted. + if _, err := pool.Exec(t.Context(), `UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model_provider}', '{"source":"deployment","status":"redacted","configuration":null}') WHERE session_id=$1`, session.ID); err != nil { + t.Fatal(err) + } + if historical, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); err != nil || historical.ModelProvider.Status != "redacted" || historical.ModelProvider.Configuration != nil { + t.Fatal("historical deployment selection changed", err) + } + } + for _, lookup := range []struct{ tenant, id string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}, {tenant, "malformed"}} { + if _, err := reader.GetSessionExecutionConfiguration(t.Context(), lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign/missing projection read differed", err) + } + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session projection remained public", err) + } + }) + } + } +} + +func TestSessionExecutionConfigurationHistoricalProvenance(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + for _, configuration := range []string{`{}`, `{"agent":{"model":null}}`, `{"agent":{"model":"historical-model"},"model_provider_configured":true}`} { + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(configuration)} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if got.Model.Source != "unknown" || got.Harness.Source != "unknown" || got.Harness.Value == nil || *got.Harness.Value != "codex" || got.ModelProvider.Source != "unknown" || got.ModelProvider.Status != "unavailable" || got.ModelProvider.Configuration != nil { + t.Fatal("historical provenance guessed") + } + if bytes.Contains([]byte(configuration), []byte("historical-model")) { + if got.Model.Value == nil || *got.Model.Value != "historical-model" { + t.Fatal("historical model lost") + } + } else if got.Model.Value != nil { + t.Fatal("missing historical model invented") + } + input.ExecutionConfiguration = executionProjectionInput("session").ExecutionConfiguration + if _, err := s.CreateSession(t.Context(), tenant, input); err != nil { + t.Fatal("historical retry changed", err) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("historical snapshot was backfilled", err) + } + } +} + +func TestSessionExecutionConfigurationRollbackAndValidation(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{42}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + for _, kind := range []string{"model", "harness", "source", "provider", "provider_mismatch", "post_projection_failure"} { + input := executionProjectionInput("session") + switch kind { + case "model": + value := "different-model" + input.ExecutionConfiguration.Model.Value = &value + case "harness": + value := "different-harness" + input.ExecutionConfiguration.Harness.Value = &value + case "source": + input.ExecutionConfiguration.Model.Source = "invalid" + case "provider": + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: &v1.ModelProviderView{Protocol: "responses", BaseURL: "https://example.com"}} + case "provider_mismatch": + input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://actual.example/v1", APIKey: "private-key-canary"} + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration.ModelProvider.Configuration.BaseURL = "https://different.example/v1" + case "post_projection_failure": + input.InitialFiles = []InitialFile{{Type: "inline", Path: "invalid-path"}} + } + if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("%s: invalid projection/creation accepted: %v", kind, err) + } + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("rejected projection left Session", err) + } + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration p LEFT JOIN sessions s ON s.id=p.session_id WHERE s.id IS NULL").Scan(&count); err != nil || count != 0 { + t.Fatal("rejected creation left orphan projection", err) + } +} + +func TestSessionExecutionConfigurationConcurrentRetryKeepsWinner(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := executionProjectionInput("session") + var wg sync.WaitGroup + projections := make(chan v1.SessionExecutionConfiguration, 8) + for i := 0; i < 8; i++ { + request := input + projection := *input.ExecutionConfiguration + if i%2 == 1 { + projection.Model.Source = "agent" + projection.Harness.Source = "deployment" + } + request.ExecutionConfiguration = &projection + wg.Add(1) + go func() { + defer wg.Done() + created, err := s.CreateSessionStream(t.Context(), tenant, request) + if err != nil { + t.Error(err) + return + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, created.Session.ID) + if err != nil { + t.Error(err) + return + } + projections <- got + }() + } + wg.Wait() + close(projections) + var winner *v1.SessionExecutionConfiguration + for got := range projections { + if winner == nil { + winner = &got + } else if !reflect.DeepEqual(*winner, got) { + t.Fatal("concurrent retry rewrote provenance") + } + } + if winner == nil { + t.Fatal("no committed projection") + } +} + +func TestSessionExecutionConfigurationSurvivesSuspendResume(t *testing.T) { + s, pool := testStore(t) + w := executionLease(t, s).Store() + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, executionProjectionInput("agent")) + if err != nil { + t.Fatal(err) + } + frozen, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + owner, err = w.ObserveRuntimeRunning(t.Context(), owner) + if err != nil { + t.Fatal(err) + } + owner, err = w.SetRuntimeCompute(t.Context(), owner, "running", json.RawMessage(`{"instance":"original"}`), nil, 0) + if err != nil { + t.Fatal(err) + } + runtimeSuspensionCompleted(t, pool, owner) + until := time.Now().Add(time.Hour) + for _, phase := range []string{"quiescing", "suspending", "suspended", "restoring", "waking", "running"} { + retained := &until + if phase == "running" { + retained = nil + } + owner = runtimeSuspensionStep(t, w, owner, phase, retained) + before, err := w.RuntimeActivity(t.Context(), owner) + if err != nil { + t.Fatal(err) + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatal("runtime transition changed execution projection", phase, err) + } + after, err := w.RuntimeActivity(t.Context(), owner) + if err != nil || !before.LastActivity.Equal(after.LastActivity) || before.WakeRequested != after.WakeRequested || before.Busy != after.Busy || before.HasCompletedTurn != after.HasCompletedTurn { + t.Fatal("configuration read changed runtime activity", err) + } + } +} diff --git a/services/core/internal/store/session_initial_input.go b/services/core/internal/store/session_initial_input.go new file mode 100644 index 000000000..ac98f7f56 --- /dev/null +++ b/services/core/internal/store/session_initial_input.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { + for _, input := range inputs { + if input.Kind != "message" { + return nil, nil, ErrInvalidInput + } + } + return validateInputs(inputs) +} + +// The Session upsert locks retries. Only the new row reserves or admits work, so a +// retry after completion or later Turns cannot submit the original input again. +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, executionConfiguration *v1.SessionExecutionConfiguration, providerSource string, deploymentRevision uuid.UUID) (sqlc.Session, *Environment, error) { + var row sqlc.Session + var environment *Environment + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + row, err = q.CreateSession(ctx, params) + if err != nil { + return err + } + if row.ID == params.ID { + var placement struct { + Environment struct { + Type string `json:"type"` + } `json:"environment"` + } + if err := json.Unmarshal(row.Configuration, &placement); err != nil { + return err + } + if placement.Environment.Type == "openai_hosted" { + if err := checkRuntimeDeploymentAdmission(ctx, q, ""); err != nil { + return err + } + } + setup, err = s.freezeEnvironmentSkills(ctx, q, tenant, setup) + if err != nil { + return err + } + if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider); err != nil { + return err + } + if err := saveSessionExecutionConfiguration(ctx, q, row, executionConfiguration, provider, providerSource, deploymentRevision); err != nil { + return err + } + if len(files) > 0 { + metadata, err := s.saveInitialFiles(ctx, q, tx, tenant, row.ID, files) + if err != nil { + return err + } + row, err = q.SetSessionInitialFileMetadata(ctx, sqlc.SetSessionInitialFileMetadataParams{ID: row.ID, Column2: metadata}) + if err != nil { + return err + } + } + if err := s.saveEnvironmentSetup(ctx, q, tenant, row.ID, setup); err != nil { + return err + } + if !setup.Empty() { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return err + } + skills, err := json.Marshal(setup.SkillMetadata()) + if err != nil { + return err + } + plugins, err := json.Marshal(setup.PluginMetadata()) + if err != nil { + return err + } + directories, err := json.Marshal(append([]string{}, setup.CapabilityDirectories...)) + if err != nil { + return err + } + row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Packages: packages, Skills: skills, Plugins: plugins, CapabilityDirectories: directories}) + if err != nil { + return err + } + } + if err := createSessionEnvironment(ctx, q, row); err != nil { + return err + } + if placement.Environment.Type == "openai_hosted" { + if err := reserveRuntimePlacement(ctx, q, row.ID, s.publicURL); err != nil { + return err + } + } + } + environment, err = sessionEnvironmentSnapshot(ctx, q, row) + if err != nil { + return err + } + audit := func() error { + var created []AuditResource + if row.ID == params.ID { + created = append(created, AuditResource{Type: "session", ID: uuid.UUID(row.ID.Bytes).String()}) + if environment != nil { + created = append(created, AuditResource{Type: "environment", ID: environment.ID, ParentID: uuid.UUID(row.ID.Bytes).String()}) + } + } + return recordWriteAudit(ctx, q, tenant, "create", "session", uuid.UUID(row.ID.Bytes).String(), "", created...) + } + if row.ID != params.ID || len(inputs) == 0 { + return audit() + } + // Creation retries use the Session request hash. Keep the internal input key + // independent of caller-supplied keys at the events endpoint. + key := uuid.NewString() + if environment != nil { + // Environment input waits for the existing preparation and leased promotion. + if err := withEnvironmentInputActivity(ctx, q, row.ID, func() error { + _, err := q.CreateEnvironmentInputReservation(ctx, sqlc.CreateEnvironmentInputReservationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: row.ID, + IdempotencyKey: key, Batch: encodedInput, IsInitial: true, + }) + return err + }); err != nil { + return err + } + } else { + for position, input := range inputs { + if _, err := admitInput(ctx, q, tenant, row.ID, key, int32(position), input); err != nil { + return err + } + } + } + if err := q.PruneSessionEvents(ctx, row.ID); err != nil { + return err + } + return audit() + }) + return row, environment, err +} diff --git a/services/agents-api/internal/store/session_initial_input_test.go b/services/core/internal/store/session_initial_input_test.go similarity index 100% rename from services/agents-api/internal/store/session_initial_input_test.go rename to services/core/internal/store/session_initial_input_test.go diff --git a/services/agents-api/internal/store/session_initial_public_test.go b/services/core/internal/store/session_initial_public_test.go similarity index 86% rename from services/agents-api/internal/store/session_initial_public_test.go rename to services/core/internal/store/session_initial_public_test.go index a1fd86075..d04ab5c48 100644 --- a/services/agents-api/internal/store/session_initial_public_test.go +++ b/services/core/internal/store/session_initial_public_test.go @@ -7,10 +7,10 @@ import ( "os/exec" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_metadata.go b/services/core/internal/store/session_metadata.go similarity index 95% rename from services/agents-api/internal/store/session_metadata.go rename to services/core/internal/store/session_metadata.go index 4899cbe12..85cad57d8 100644 --- a/services/agents-api/internal/store/session_metadata.go +++ b/services/core/internal/store/session_metadata.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/session_metadata_test.go b/services/core/internal/store/session_metadata_test.go similarity index 100% rename from services/agents-api/internal/store/session_metadata_test.go rename to services/core/internal/store/session_metadata_test.go diff --git a/services/agents-api/internal/store/session_model_execution.go b/services/core/internal/store/session_model_execution.go similarity index 92% rename from services/agents-api/internal/store/session_model_execution.go rename to services/core/internal/store/session_model_execution.go index e12868733..8332bc824 100644 --- a/services/agents-api/internal/store/session_model_execution.go +++ b/services/core/internal/store/session_model_execution.go @@ -7,8 +7,8 @@ import ( "errors" "io" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/session_model_execution_http_test.go b/services/core/internal/store/session_model_execution_http_test.go similarity index 91% rename from services/agents-api/internal/store/session_model_execution_http_test.go rename to services/core/internal/store/session_model_execution_http_test.go index 9fe017a8f..f52533b49 100644 --- a/services/agents-api/internal/store/session_model_execution_http_test.go +++ b/services/core/internal/store/session_model_execution_http_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_model_execution_test.go b/services/core/internal/store/session_model_execution_test.go similarity index 95% rename from services/agents-api/internal/store/session_model_execution_test.go rename to services/core/internal/store/session_model_execution_test.go index 67481c70b..e22cff186 100644 --- a/services/agents-api/internal/store/session_model_execution_test.go +++ b/services/core/internal/store/session_model_execution_test.go @@ -4,8 +4,8 @@ import ( "bytes" "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" "testing" ) diff --git a/services/agents-api/internal/store/session_model_options_test.go b/services/core/internal/store/session_model_options_test.go similarity index 92% rename from services/agents-api/internal/store/session_model_options_test.go rename to services/core/internal/store/session_model_options_test.go index 4415843e8..cb9f6409c 100644 --- a/services/agents-api/internal/store/session_model_options_test.go +++ b/services/core/internal/store/session_model_options_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_reference_retry_public_test.go b/services/core/internal/store/session_reference_retry_public_test.go similarity index 90% rename from services/agents-api/internal/store/session_reference_retry_public_test.go rename to services/core/internal/store/session_reference_retry_public_test.go index 90975d0b6..f72d91cde 100644 --- a/services/agents-api/internal/store/session_reference_retry_public_test.go +++ b/services/core/internal/store/session_reference_retry_public_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/session_transaction.go b/services/core/internal/store/session_transaction.go similarity index 97% rename from services/agents-api/internal/store/session_transaction.go rename to services/core/internal/store/session_transaction.go index 5ccce0b0b..0b0e8e407 100644 --- a/services/agents-api/internal/store/session_transaction.go +++ b/services/core/internal/store/session_transaction.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) // All Turn admission and lifecycle writes lock the tenant-scoped Session first. diff --git a/services/core/internal/store/session_write_audit.go b/services/core/internal/store/session_write_audit.go new file mode 100644 index 000000000..bc96bd6f2 --- /dev/null +++ b/services/core/internal/store/session_write_audit.go @@ -0,0 +1,20 @@ +package store + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// AuditSessionOperation records an authorized public no-op or creation replay. +// It cannot create ownership or admit execution work. +func (s *Store) AuditSessionOperation(ctx context.Context, tenantID, sessionID, action string) error { + if action != "create" && action != "send_events" { + return ErrInvalidInput + } + return s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return recordWriteAudit(ctx, q, tenantID, action, "session", uuid.UUID(session.Bytes).String(), "") + }) +} diff --git a/services/core/internal/store/session_write_audit_test.go b/services/core/internal/store/session_write_audit_test.go new file mode 100644 index 000000000..78a2f7ab2 --- /dev/null +++ b/services/core/internal/store/session_write_audit_test.go @@ -0,0 +1,227 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" +) + +func sessionAuditContext(t *testing.T, tenant, key string) context.Context { + t.Helper() + digest := strings.Repeat(key, 64) + return writeaudit.WithSource(t.Context(), writeaudit.Source{TenantID: tenant, KeyID: "static:" + digest, + Name: "safe key", Prefix: digest[:8], Kind: "static", RequestID: uuid.NewString(), TraceID: "shared-trace"}) +} + +func sessionAuditCount(t *testing.T, s *Store, tenant string, want int) { + t.Helper() + var count int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != want { + t.Fatalf("audit count = %d, want %d: %v", count, want, err) + } +} + +func rejectSessionAudit(t *testing.T, s *Store, ctx context.Context) { + t.Helper() + source, _ := writeaudit.FromContext(ctx) + name := "reject_session_audit_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, err := s.pool.Exec(t.Context(), fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN IF NEW.request_id = '%s' THEN RAISE EXCEPTION 'audit insert rejected'; END IF; RETURN NEW; END $$; +CREATE TRIGGER %s BEFORE INSERT ON write_audit_operations FOR EACH ROW EXECUTE FUNCTION %s();`, name, source.RequestID, name, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := s.pool.Exec(context.Background(), fmt.Sprintf("DROP TRIGGER %s ON write_audit_operations; DROP FUNCTION %s();", name, name)); err != nil { + t.Error(err) + } + }) +} + +func TestSessionWriteAuditCreationReplayNoopAndDeletion(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-create", "self_hosted", "/workspace") + created, err := s.CreateSession(sessionAuditContext(t, tenant, "a"), tenant, input) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateSession(sessionAuditContext(t, tenant, "b"), tenant, input); err != nil { + t.Fatal(err) + } + if _, err := s.GetSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { + t.Fatal(err) + } + sessionAuditCount(t, s, tenant, 2) + for _, resource := range []string{created.ID, env.ID} { + var key string + if err := s.pool.QueryRow(t.Context(), `SELECT o.key_id FROM write_audit_owners a JOIN write_audit_operations o ON o.id=a.operation_id WHERE a.tenant_id=$1 AND a.resource_id=$2`, tenant, resource).Scan(&key); err != nil || key != "static:"+strings.Repeat("a", 64) { + t.Fatal("retry replaced creator", key, err) + } + } + for _, action := range []string{"create", "send_events"} { + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, action); err != nil { + t.Fatal(err) + } + } + if _, err := s.UpdateSessionMetadata(sessionAuditContext(t, tenant, "b"), tenant, created.ID, map[string]string{"private": "not in audit"}); err != nil { + t.Fatal(err) + } + for range 2 { + if err := s.DeleteSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { + t.Fatal(err) + } + } + sessionAuditCount(t, s, tenant, 7) + var history string + if err := s.pool.QueryRow(t.Context(), "SELECT jsonb_agg(to_jsonb(o))::text FROM write_audit_operations o WHERE tenant_id=$1", tenant).Scan(&history); err != nil || strings.Contains(history, "not in audit") || strings.Contains(history, "/workspace") { + t.Fatal("payload entered audit", err) + } + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, "send_events"); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted no-op accepted", err) + } + sessionAuditCount(t, s, tenant, 7) +} + +func TestSessionWriteAuditConcurrentCreation(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-race", "self_hosted", "/workspace") + var group sync.WaitGroup + for range 8 { + ctx := sessionAuditContext(t, tenant, "a") + group.Go(func() { + if _, err := s.CreateSession(ctx, tenant, input); err != nil { + t.Error(err) + } + }) + } + group.Wait() + sessionAuditCount(t, s, tenant, 8) + var owners, creates int + if err := s.pool.QueryRow(t.Context(), `SELECT count(*),count(DISTINCT operation_id) FROM write_audit_owners WHERE tenant_id=$1`, tenant).Scan(&owners, &creates); err != nil || owners != 2 || creates != 1 { + t.Fatal("creation race attribution", owners, creates, err) + } +} + +func TestSessionWriteAuditRollback(t *testing.T) { + for _, operation := range []string{"create", "update", "delete", "events", "noop", "reserve"} { + t.Run(operation, func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-rollback", "self_hosted", "/workspace") + ctx := sessionAuditContext(t, tenant, "a") + rejectSessionAudit(t, s, ctx) + if operation == "create" { + if _, err := s.CreateSession(ctx, tenant, input); err == nil { + t.Fatal("creation bypassed audit failure") + } + var count int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("creation did not roll back", count, err) + } + return + } + created, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + switch operation { + case "update": + _, err = s.UpdateSessionMetadata(ctx, tenant, created.ID, map[string]string{"new": "value"}) + case "delete": + err = s.DeleteSession(ctx, tenant, created.ID) + case "events": + _, err = s.SubmitInputs(ctx, tenant, created.ID, "events", []Input{messageInput("private")}) + case "noop": + err = s.AuditSessionOperation(ctx, tenant, created.ID, "send_events") + case "reserve": + _, err = s.ReserveEnvironmentInput(ctx, tenant, created.ID, "reserve", []Input{messageInput("private")}) + } + if err == nil { + t.Fatal("mutation bypassed audit failure") + } + got, err := s.GetSession(t.Context(), tenant, created.ID) + if err != nil || len(got.Metadata) != 0 { + t.Fatal("resource update/deletion survived rollback", got, err) + } + var inputs, reservations int + if err := s.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM turn_inputs WHERE session_id=$1),(SELECT count(*) FROM environment_input_reservations WHERE session_id=$1)`, created.ID).Scan(&inputs, &reservations); err != nil || inputs != 0 || reservations != 0 { + t.Fatal("input survived rollback", inputs, reservations, err) + } + sessionAuditCount(t, s, tenant, 0) + }) + } +} + +func TestEventsWriteAuditAdmissionAndReplay(t *testing.T) { + for _, prepared := range []bool{false, true} { + t.Run(fmt.Sprint(prepared), func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-events", "self_hosted", "/workspace") + created, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + submit := func(ctx context.Context) error { + if prepared { + _, err := s.ReserveEnvironmentInput(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) + return err + } + _, err := s.SubmitInputs(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) + return err + } + first := sessionAuditContext(t, tenant, "a") + for _, ctx := range []context.Context{first, first, sessionAuditContext(t, tenant, "b")} { + if err := submit(ctx); err != nil { + t.Fatal(err) + } + } + sessionAuditCount(t, s, tenant, 2) + var owners int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1", tenant).Scan(&owners); err != nil || owners != 0 { + t.Fatal("events acquired historical ownership", owners, err) + } + }) + } +} + +func TestSessionWriteAuditInitialInputAndHistoricalReplay(t *testing.T) { + for _, kind := range []string{"none", "self_hosted"} { + t.Run(kind, func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-initial", kind, "/workspace") + input.InitialInputs = []Input{messageInput("private initial message")} + created, err := s.CreateSessionStream(sessionAuditContext(t, tenant, "a"), tenant, input) + if err != nil || !created.Created { + t.Fatal(created, err) + } + sessionAuditCount(t, s, tenant, 1) + input.IdempotencyKey = "legacy" + legacy, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, legacy.ID, "create"); err != nil { + t.Fatal(err) + } + var owners int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_id=$2", tenant, legacy.ID).Scan(&owners); err != nil || owners != 0 { + t.Fatal("replay attributed historical resource", owners, err) + } + sessionAuditCount(t, s, tenant, 2) + }) + } +} diff --git a/services/agents-api/internal/store/sessions.go b/services/core/internal/store/sessions.go similarity index 97% rename from services/agents-api/internal/store/sessions.go rename to services/core/internal/store/sessions.go index 189a6be47..88eba9fa7 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/core/internal/store/sessions.go @@ -17,11 +17,11 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" ) var ( diff --git a/services/agents-api/internal/store/sessions_test.go b/services/core/internal/store/sessions_test.go similarity index 98% rename from services/agents-api/internal/store/sessions_test.go rename to services/core/internal/store/sessions_test.go index 235801a3a..022d9b428 100644 --- a/services/agents-api/internal/store/sessions_test.go +++ b/services/core/internal/store/sessions_test.go @@ -12,7 +12,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations" + "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" ) func testStore(t *testing.T) (*Store, *pgxpool.Pool) { diff --git a/services/agents-api/internal/store/skill_default_metadata_migration_test.go b/services/core/internal/store/skill_default_metadata_migration_test.go similarity index 100% rename from services/agents-api/internal/store/skill_default_metadata_migration_test.go rename to services/core/internal/store/skill_default_metadata_migration_test.go diff --git a/services/agents-api/internal/store/skill_default_metadata_test.go b/services/core/internal/store/skill_default_metadata_test.go similarity index 97% rename from services/agents-api/internal/store/skill_default_metadata_test.go rename to services/core/internal/store/skill_default_metadata_test.go index 86efffa18..005ed426a 100644 --- a/services/agents-api/internal/store/skill_default_metadata_test.go +++ b/services/core/internal/store/skill_default_metadata_test.go @@ -8,7 +8,7 @@ import ( "strconv" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/skill_selectors_public_test.go b/services/core/internal/store/skill_selectors_public_test.go similarity index 87% rename from services/agents-api/internal/store/skill_selectors_public_test.go rename to services/core/internal/store/skill_selectors_public_test.go index cbb342595..092a9ff7d 100644 --- a/services/agents-api/internal/store/skill_selectors_public_test.go +++ b/services/core/internal/store/skill_selectors_public_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/skill_version_deletion_public_test.go b/services/core/internal/store/skill_version_deletion_public_test.go similarity index 94% rename from services/agents-api/internal/store/skill_version_deletion_public_test.go rename to services/core/internal/store/skill_version_deletion_public_test.go index 290278ecc..926d59122 100644 --- a/services/agents-api/internal/store/skill_version_deletion_public_test.go +++ b/services/core/internal/store/skill_version_deletion_public_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/skill_version_deletion_test.go b/services/core/internal/store/skill_version_deletion_test.go similarity index 98% rename from services/agents-api/internal/store/skill_version_deletion_test.go rename to services/core/internal/store/skill_version_deletion_test.go index c9c16f041..b4f169d6f 100644 --- a/services/agents-api/internal/store/skill_version_deletion_test.go +++ b/services/core/internal/store/skill_version_deletion_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/skill_versions.go b/services/core/internal/store/skill_versions.go similarity index 97% rename from services/agents-api/internal/store/skill_versions.go rename to services/core/internal/store/skill_versions.go index cdaeeecc4..12152b98b 100644 --- a/services/agents-api/internal/store/skill_versions.go +++ b/services/core/internal/store/skill_versions.go @@ -5,8 +5,8 @@ import ( "errors" "math" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" ) diff --git a/services/core/internal/store/skills.go b/services/core/internal/store/skills.go new file mode 100644 index 000000000..930ac7af8 --- /dev/null +++ b/services/core/internal/store/skills.go @@ -0,0 +1,194 @@ +package store + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type Skill struct { + ID string + Name string + Description string + CreatedAt time.Time + DefaultVersion int64 + LatestVersion int64 +} + +type SkillVersion struct { + ID string + SkillID string + Version int64 + Name string + Description string + CreatedAt time.Time +} + +func (s *Store) CreateSkill(ctx context.Context, tenantID string, archive []byte) (Skill, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Skill{}, err + } + metadata, err := agentskill.Inspect(archive) + if err != nil { + return Skill{}, ErrInvalidInput + } + var result Skill + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + id := pgtype.UUID{Bytes: uuid.New(), Valid: true} + row, err := q.CreateSkill(ctx, sqlc.CreateSkillParams{ID: id, TenantID: tenant, Name: metadata.Name, Description: metadata.Description}) + if err != nil { + return err + } + initial, err := s.saveSkillVersion(ctx, q, tenant, id, 1, metadata, archive) + if err != nil { + return err + } + result = skillFromRow(row) + return recordWriteAudit(ctx, q, tenantID, "create", "skill", result.ID, "", + AuditResource{Type: "skill", ID: result.ID}, + AuditResource{Type: "skill_version", ID: initial.ID, ParentID: result.ID}) + }) + return result, err +} + +func (s *Store) GetSkill(ctx context.Context, tenantID, skillID string) (Skill, error) { + tenant, id, err := skillPathIDs(tenantID, skillID) + if err != nil { + return Skill{}, err + } + row, err := s.queries.GetSkill(ctx, sqlc.GetSkillParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Skill{}, ErrNotFound + } + return skillFromRow(row), err +} + +func (s *Store) UpdateSkillDefault(ctx context.Context, tenantID, skillID, version string) (Skill, error) { + tenant, id, err := skillPathIDs(tenantID, skillID) + if err != nil { + return Skill{}, err + } + number, err := skillVersionNumber(version) + if err != nil { + return Skill{}, err + } + var result Skill + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + if _, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: id}); err != nil { + return err + } + version, err := q.GetSkillVersion(ctx, sqlc.GetSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) + if err != nil { + return err + } + row, err := q.SetDefaultSkillVersion(ctx, sqlc.SetDefaultSkillVersionParams{TenantID: tenant, ID: id, DefaultVersion: number, Name: version.Name, Description: version.Description}) + if err != nil { + return err + } + result = skillFromRow(row) + return recordWriteAudit(ctx, q, tenantID, "update_default_version", "skill", result.ID, "") + }) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return result, err +} + +func (s *Store) DeleteSkill(ctx context.Context, tenantID, skillID string) error { + tenant, id, err := skillPathIDs(tenantID, skillID) + if err != nil { + return err + } + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + if _, err := q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}); err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "delete", "skill", skillID, "") + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err +} + +func (s *Store) saveSkillVersion(ctx context.Context, q *sqlc.Queries, tenant, skill pgtype.UUID, version int64, metadata agentskill.Metadata, archive []byte) (SkillVersion, error) { + id := pgtype.UUID{Bytes: uuid.New(), Valid: true} + body, err := s.credentialCipher.SealSkill(archive, skillBinding(tenant, skill, id, version)) + if err != nil { + return SkillVersion{}, err + } + row, err := q.CreateSkillVersion(ctx, sqlc.CreateSkillVersionParams{ID: id, TenantID: tenant, SkillID: skill, Version: version, Name: metadata.Name, Description: metadata.Description, Contents: body}) + return skillVersionFromRow(sqlc.GetSkillVersionRow(row)), err +} + +func skillBinding(tenant, skill, versionID pgtype.UUID, version int64) credentialcrypto.SkillBinding { + return credentialcrypto.SkillBinding{TenantID: uuid.UUID(tenant.Bytes).String(), SkillID: uuid.UUID(skill.Bytes).String(), VersionID: uuid.UUID(versionID.Bytes).String(), Version: strconv.FormatInt(version, 10)} +} + +func skillIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { + tenant, err := parseID(tenantID) + if err != nil { + return tenant, pgtype.UUID{}, err + } + id, err := skillResourceID(skillID, "skill_") + return tenant, id, err +} + +func skillResourceID(value, prefix string) (pgtype.UUID, error) { + id, err := uuid.Parse(strings.TrimPrefix(value, prefix)) + if err != nil || id == uuid.Nil || value != prefix+id.String() { + return pgtype.UUID{}, ErrNotFound + } + return pgtype.UUID{Bytes: id, Valid: true}, nil +} + +func skillVersionNumber(value string) (int64, error) { + number, err := strconv.ParseInt(value, 10, 64) + if err != nil || number < 1 || strconv.FormatInt(number, 10) != value { + return 0, ErrInvalidInput + } + return number, nil +} + +// skillPathIDs resolves a Skill path identifier. Like parsePathID, a malformed +// value resolves to an identifier that never exists, so the request follows the +// missing-Skill path. Request-body references keep skillIDs. +func skillPathIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if errors.Is(err, ErrNotFound) { + return tenant, pgtype.UUID{Bytes: uuid.Max, Valid: true}, nil + } + return tenant, id, err +} + +// skillPathVersion resolves a version path segment. Versions start at 1, so a +// malformed segment resolves to the never-assigned version 0 and follows the +// missing-version path. Request-body selectors keep skillVersionNumber. +func skillPathVersion(value string) int64 { + number, err := skillVersionNumber(value) + if err != nil { + return 0 + } + return number +} + +func skillFromRow(row sqlc.Skill) Skill { + return Skill{ID: "skill_" + uuid.UUID(row.ID.Bytes).String(), Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time, DefaultVersion: row.DefaultVersion, LatestVersion: row.LatestVersion} +} + +func skillVersionFromRow(row sqlc.GetSkillVersionRow) SkillVersion { + return SkillVersion{ID: "skillver_" + uuid.UUID(row.ID.Bytes).String(), SkillID: "skill_" + uuid.UUID(row.SkillID.Bytes).String(), Version: row.Version, Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/core/internal/store/skills_list.go b/services/core/internal/store/skills_list.go new file mode 100644 index 000000000..54a7cc11a --- /dev/null +++ b/services/core/internal/store/skills_list.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type SkillPage struct { + Skills []Skill + HasMore bool +} + +type SkillVersionPage struct { + Versions []SkillVersion + HasMore bool +} + +// ListSkills and ListSkillVersions accept limit 0: the page is empty and HasMore +// reports whether any resource follows the cursor. +func (s *Store) ListSkills(ctx context.Context, tenantID, after string, limit int, ascending bool) (SkillPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SkillPage{}, err + } + if limit < 0 || limit > 100 { + return SkillPage{}, ErrInvalidInput + } + params := sqlc.ListSkillsParams{TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} + if after != "" { + cursor, err := s.GetSkill(ctx, tenantID, after) + if err != nil { + return SkillPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: cursor.CreatedAt, Valid: true} + params.AfterID, _ = skillResourceID(cursor.ID, "skill_") + } + rows, err := s.queries.ListSkills(ctx, params) + if err != nil { + return SkillPage{}, err + } + page := SkillPage{Skills: make([]Skill, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + page.Skills = append(page.Skills, skillFromRow(row)) + } + return page, nil +} + +func (s *Store) ListSkillVersions(ctx context.Context, tenantID, skillID, after string, limit int, ascending bool) (SkillVersionPage, error) { + tenant, id, err := skillPathIDs(tenantID, skillID) + if err != nil { + return SkillVersionPage{}, err + } + if limit < 0 || limit > 100 { + return SkillVersionPage{}, ErrInvalidInput + } + if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { + return SkillVersionPage{}, err + } + params := sqlc.ListSkillVersionsParams{TenantID: tenant, SkillID: id, PageLimit: int32(limit + 1), Ascending: ascending} + if after != "" { + // A value that is not a version resource ID is invalid; a well-formed + // version missing from this tenant, including another tenant's, is not + // found; another Skill's version in this tenant does not match. + if !strings.HasPrefix(after, "skillver") { + return SkillVersionPage{}, skillVersionCursorPrefix(after) + } + cursorID, err := skillResourceID(after, "skillver_") + if err != nil { + return SkillVersionPage{}, err + } + cursor, err := s.queries.GetSkillVersionByID(ctx, sqlc.GetSkillVersionByIDParams{TenantID: tenant, ID: cursorID}) + if errors.Is(err, pgx.ErrNoRows) { + return SkillVersionPage{}, ErrNotFound + } + if err != nil { + return SkillVersionPage{}, err + } + if cursor.SkillID != id { + // As for Artifacts, a Skill deleted since its lookup stays not found. + if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { + return SkillVersionPage{}, err + } + return SkillVersionPage{}, errSkillVersionCursorParent + } + params.AfterVersion = pgtype.Int8{Int64: cursor.Version, Valid: true} + } + rows, err := s.queries.ListSkillVersions(ctx, params) + if err != nil { + return SkillVersionPage{}, err + } + page := SkillVersionPage{Versions: make([]SkillVersion, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + page.Versions = append(page.Versions, skillVersionFromRow(sqlc.GetSkillVersionRow(row))) + } + return page, nil +} diff --git a/services/agents-api/internal/store/skills_list_test.go b/services/core/internal/store/skills_list_test.go similarity index 96% rename from services/agents-api/internal/store/skills_list_test.go rename to services/core/internal/store/skills_list_test.go index af3c39017..311c4e739 100644 --- a/services/agents-api/internal/store/skills_list_test.go +++ b/services/core/internal/store/skills_list_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/skills_public_test.go b/services/core/internal/store/skills_public_test.go similarity index 87% rename from services/agents-api/internal/store/skills_public_test.go rename to services/core/internal/store/skills_public_test.go index d024e3428..c82a6c519 100644 --- a/services/agents-api/internal/store/skills_public_test.go +++ b/services/core/internal/store/skills_public_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/skills_test.go b/services/core/internal/store/skills_test.go new file mode 100644 index 000000000..58b520e2c --- /dev/null +++ b/services/core/internal/store/skills_test.go @@ -0,0 +1,166 @@ +package store + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "sort" + "strconv" + "sync" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +func skillArchive(t *testing.T, marker string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + file, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = fmt.Fprintf(file, "---\nname: proof\ndescription: Verify a versioned Skill.\n---\n%s", marker); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestSkillsOwnershipEncryptionAndVersions(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + archive := skillArchive(t, "confidential-skill-canary") + created, err := s.CreateSkill(t.Context(), tenant, archive) + if err != nil { + t.Fatal(err) + } + if created.DefaultVersion != 1 || created.LatestVersion != 1 { + t.Fatal("initial pointers", created) + } + t.Cleanup(func() { _ = s.DeleteSkill(t.Context(), tenant, created.ID) }) + metadata, err := New(pool).GetSkill(t.Context(), tenant, created.ID) + if err != nil || metadata.Name != "proof" { + t.Fatal("metadata requires no content key", err) + } + var contents []byte + if err = pool.QueryRow(t.Context(), "SELECT contents FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&contents); err != nil { + t.Fatal(err) + } + if bytes.Contains(contents, []byte("confidential-skill-canary")) { + t.Fatal("plaintext bundle persisted") + } + version, body, err := s.ReadSkillVersion(t.Context(), tenant, created.ID, "1") + if err != nil || !bytes.Equal(body, archive) || version.Version != 1 { + t.Fatal("content round trip", err) + } + if _, err = s.GetSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign metadata", err) + } + if _, _, err = s.ReadSkillVersion(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign content", err) + } + if _, err = s.CreateSkillVersion(t.Context(), foreign, created.ID, archive, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign version", err) + } + if _, err = s.UpdateSkillDefault(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign pointer", err) + } + if err = s.DeleteSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign delete", err) + } + if _, err = s.ListSkills(t.Context(), foreign, created.ID, 20, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + if _, err = s.ListSkillVersions(t.Context(), foreign, created.ID, "", 20, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign versions", err) + } + + const count = 8 + results := make(chan SkillVersion, count) + failures := make(chan error, count) + var group sync.WaitGroup + for range count { + group.Add(1) + go func() { + defer group.Done() + version, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, false) + if err != nil { + failures <- err + } else { + results <- version + } + }() + } + group.Wait() + close(results) + close(failures) + for err := range failures { + t.Fatal(err) + } + numbers := []int{} + for version := range results { + numbers = append(numbers, int(version.Version)) + } + sort.Ints(numbers) + for i, number := range numbers { + if number != i+2 { + t.Fatal("concurrent version allocation", numbers) + } + } + if len(numbers) != count { + t.Fatal("missing versions", numbers) + } + current, err := s.GetSkill(t.Context(), tenant, created.ID) + if err != nil || current.DefaultVersion != 1 || current.LatestVersion != count+1 { + t.Fatal("concurrent pointers", current, err) + } + first, err := s.ListSkillVersions(t.Context(), tenant, created.ID, "", 3, true) + if err != nil || !first.HasMore || len(first.Versions) != 3 || first.Versions[0].Version != 1 { + t.Fatal("first page", first, err) + } + next, err := s.ListSkillVersions(t.Context(), tenant, created.ID, first.Versions[2].ID, 20, true) + if err != nil || next.HasMore || len(next.Versions) != 6 || next.Versions[0].Version != 4 { + t.Fatal("version resource cursor", next, err) + } + var cursorErr *InvalidCursorError + if _, err = s.ListSkillVersions(t.Context(), tenant, created.ID, "3", 20, true); !errors.As(err, &cursorErr) || cursorErr.Message != "Invalid 'after': '3'. Expected an ID that begins with 'skillver'." { + t.Fatal("numeric version is not a cursor", err) + } + if _, err = s.UpdateSkillDefault(t.Context(), tenant, created.ID, "999"); !errors.Is(err, ErrNotFound) { + t.Fatal("missing default", err) + } + updated, err := s.UpdateSkillDefault(t.Context(), tenant, created.ID, "3") + if err != nil || updated.DefaultVersion != 3 { + t.Fatal("default update", err) + } + if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, "3"); !errors.Is(err, ErrDefaultSkillVersion) { + t.Fatal("default deletion", err) + } + if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, strconv.Itoa(count+1)); err != nil { + t.Fatal(err) + } + added, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, true) + if err != nil || added.Version != count+2 { + t.Fatal("deleted version number reused", added, err) + } + if err = s.DeleteSkill(t.Context(), tenant, created.ID); err != nil { + t.Fatal(err) + } + if _, _, err = s.ReadSkillVersion(t.Context(), tenant, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("cascaded content", err) + } + var remaining int + if err = pool.QueryRow(t.Context(), "SELECT count(*) FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&remaining); err != nil || remaining != 0 { + t.Fatal("orphan content", remaining, err) + } +} diff --git a/services/agents-api/internal/store/source_file_writer.go b/services/core/internal/store/source_file_writer.go similarity index 100% rename from services/agents-api/internal/store/source_file_writer.go rename to services/core/internal/store/source_file_writer.go diff --git a/services/core/internal/store/source_files.go b/services/core/internal/store/source_files.go new file mode 100644 index 000000000..036ce6ff9 --- /dev/null +++ b/services/core/internal/store/source_files.go @@ -0,0 +1,235 @@ +package store + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +const MaxSourceFileBytes int64 = 512 << 20 + +var ErrSourceFileTooLarge = errors.New("source file exceeds storage limit") + +type SourceFile struct { + ID string + Filename string + Purpose string + SizeBytes int64 + CreatedAt time.Time +} + +type SourceFileUpload struct { + Filename string + Purpose string +} + +type SourceFilePage struct { + Files []SourceFile + NextCursor string +} + +// CreateSourceFile commits only after the complete upload envelope has validated. +func (s *Store) CreateSourceFile(ctx context.Context, tenantID string, upload func(io.Writer) (SourceFileUpload, error)) (SourceFile, error) { + tenant, err := parseID(tenantID) + if err != nil || upload == nil { + return SourceFile{}, ErrInvalidInput + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return SourceFile{}, err + } + defer tx.Rollback(context.Background()) + objects := tx.LargeObjects() + oid, err := objects.Create(ctx, 0) + if err != nil { + return SourceFile{}, err + } + body, err := objects.Open(ctx, oid, pgx.LargeObjectModeWrite) + if err != nil { + return SourceFile{}, err + } + writer := newSourceFileWriter(body) + input, err := upload(writer) + if writer.err != nil { + return SourceFile{}, writer.err + } + if err != nil { + return SourceFile{}, err + } + if !validSourceFilename(input.Filename) || input.Purpose != "user_data" { + return SourceFile{}, ErrInvalidInput + } + if err := body.Close(); err != nil { + return SourceFile{}, err + } + row, err := s.queries.WithTx(tx).CreateSourceFile(ctx, sqlc.CreateSourceFileParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Filename: input.Filename, Purpose: input.Purpose, BodyOid: pgtype.Uint32{Uint32: oid, Valid: true}, + SizeBytes: writer.size, Sha256: hex.EncodeToString(writer.hash.Sum(nil)), + }) + if err != nil { + return SourceFile{}, fmt.Errorf("create source file: %w", err) + } + resource := sourceFileFromRow(row) + if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "create", "file", resource.ID, "", AuditResource{Type: "file", ID: resource.ID}); err != nil { + return SourceFile{}, err + } + if err := tx.Commit(ctx); err != nil { + return SourceFile{}, err + } + return sourceFileFromRow(row), nil +} + +func (s *Store) GetSourceFile(ctx context.Context, tenantID, fileID string) (SourceFile, error) { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return SourceFile{}, err + } + row, err := s.queries.GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return SourceFile{}, ErrNotFound + } + if err != nil { + return SourceFile{}, err + } + return sourceFileFromRow(row), nil +} + +func (s *Store) ListSourceFiles(ctx context.Context, tenantID, cursor string, limit int, ascending bool, purpose *string) (SourceFilePage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SourceFilePage{}, err + } + if limit < 1 || limit > 10000 { + return SourceFilePage{}, fmt.Errorf("%w: internal page size must be 1..10000", ErrInvalidInput) + } + params := sqlc.ListSourceFilesParams{ + TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, + AfterID: pgtype.UUID{Valid: true}, + } + if purpose != nil { + if !utf8.ValidString(*purpose) || strings.ContainsRune(*purpose, '\x00') { + return SourceFilePage{}, ErrInvalidInput + } + params.Purpose = pgtype.Text{String: *purpose, Valid: true} + } + if cursor != "" { + after, err := s.GetSourceFile(ctx, tenantID, cursor) + if err != nil { + return SourceFilePage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + _, params.AfterID, _ = sourceFileIDs(tenantID, after.ID) + } + rows, err := s.queries.ListSourceFiles(ctx, params) + if err != nil { + return SourceFilePage{}, fmt.Errorf("list source files: %w", err) + } + page := SourceFilePage{Files: make([]SourceFile, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = sourceFileFromRow(rows[limit-1]).ID + rows = rows[:limit] + } + for _, row := range rows { + page.Files = append(page.Files, sourceFileFromRow(row)) + } + return page, nil +} + +// ReadSourceFile retains an authorized immutable snapshot during concurrent deletion. +func (s *Store) ReadSourceFile(ctx context.Context, tenantID, fileID string, consume func(SourceFile, io.Reader) error) error { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return err + } + if consume == nil { + return ErrInvalidInput + } + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + row, err := s.queries.WithTx(tx).GetSourceFile(ctx, sqlc.GetSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if err := consumeSourceFile(ctx, tx, row, consume); err != nil { + return err + } + return tx.Commit(ctx) +} + +func consumeSourceFile(ctx context.Context, tx pgx.Tx, row sqlc.SourceFile, consume func(SourceFile, io.Reader) error) error { + objects := tx.LargeObjects() + body, err := objects.Open(ctx, row.BodyOid.Uint32, pgx.LargeObjectModeRead) + if err != nil { + return err + } + if err := consume(sourceFileFromRow(row), body); err != nil { + return err + } + return body.Close() +} + +func (s *Store) DeleteSourceFile(ctx context.Context, tenantID, fileID string) error { + tenant, id, err := sourceFileIDs(tenantID, fileID) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + oid, err := s.queries.WithTx(tx).DeleteSourceFile(ctx, sqlc.DeleteSourceFileParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + objects := tx.LargeObjects() + if err := objects.Unlink(ctx, oid.Uint32); err != nil { + return err + } + if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "delete", "file", fileID, ""); err != nil { + return err + } + return tx.Commit(ctx) +} + +func sourceFileIDs(tenantID, fileID string) (pgtype.UUID, pgtype.UUID, error) { + tenant, err := parseID(tenantID) + if err != nil { + return tenant, pgtype.UUID{}, err + } + id, err := uuid.Parse(strings.TrimPrefix(fileID, "file-")) + if err != nil || id == uuid.Nil || fileID != "file-"+id.String() { + return tenant, pgtype.UUID{}, ErrNotFound + } + return tenant, pgtype.UUID{Bytes: id, Valid: true}, nil +} + +func validSourceFilename(name string) bool { + return len(name) >= 1 && len(name) <= 1024 && utf8.ValidString(name) && !strings.ContainsRune(name, '\x00') +} + +func sourceFileFromRow(row sqlc.SourceFile) SourceFile { + return SourceFile{ID: "file-" + uuid.UUID(row.ID.Bytes).String(), Filename: row.Filename, + Purpose: row.Purpose, SizeBytes: row.SizeBytes, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/agents-api/internal/store/source_files_errors_public_test.go b/services/core/internal/store/source_files_errors_public_test.go similarity index 86% rename from services/agents-api/internal/store/source_files_errors_public_test.go rename to services/core/internal/store/source_files_errors_public_test.go index af320e0bb..a5b836eee 100644 --- a/services/agents-api/internal/store/source_files_errors_public_test.go +++ b/services/core/internal/store/source_files_errors_public_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/source_files_list_test.go b/services/core/internal/store/source_files_list_test.go similarity index 100% rename from services/agents-api/internal/store/source_files_list_test.go rename to services/core/internal/store/source_files_list_test.go diff --git a/services/agents-api/internal/store/source_files_test.go b/services/core/internal/store/source_files_test.go similarity index 100% rename from services/agents-api/internal/store/source_files_test.go rename to services/core/internal/store/source_files_test.go diff --git a/services/agents-api/internal/store/steering_receipts_test.go b/services/core/internal/store/steering_receipts_test.go similarity index 93% rename from services/agents-api/internal/store/steering_receipts_test.go rename to services/core/internal/store/steering_receipts_test.go index df620a347..b31ccd39e 100644 --- a/services/agents-api/internal/store/steering_receipts_test.go +++ b/services/core/internal/store/steering_receipts_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestExecutionDurableInputReceiptLifetime(t *testing.T) { diff --git a/services/agents-api/internal/store/stream_authority_http_test.go b/services/core/internal/store/stream_authority_http_test.go similarity index 94% rename from services/agents-api/internal/store/stream_authority_http_test.go rename to services/core/internal/store/stream_authority_http_test.go index 9dfc0eb9e..75e7aa64b 100644 --- a/services/agents-api/internal/store/stream_authority_http_test.go +++ b/services/core/internal/store/stream_authority_http_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/structured_output_dispatch_test.go b/services/core/internal/store/structured_output_dispatch_test.go similarity index 88% rename from services/agents-api/internal/store/structured_output_dispatch_test.go rename to services/core/internal/store/structured_output_dispatch_test.go index c492d7662..733f70546 100644 --- a/services/agents-api/internal/store/structured_output_dispatch_test.go +++ b/services/core/internal/store/structured_output_dispatch_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestStructuredOutputDispatchRechecksOperationQualification(t *testing.T) { diff --git a/services/agents-api/internal/store/structured_output_native_test.go b/services/core/internal/store/structured_output_native_test.go similarity index 93% rename from services/agents-api/internal/store/structured_output_native_test.go rename to services/core/internal/store/structured_output_native_test.go index a91bab1f8..412cef7f6 100644 --- a/services/agents-api/internal/store/structured_output_native_test.go +++ b/services/core/internal/store/structured_output_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/core/internal/store/subagent_coordination.go b/services/core/internal/store/subagent_coordination.go new file mode 100644 index 000000000..3db45b1d6 --- /dev/null +++ b/services/core/internal/store/subagent_coordination.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func coordinationItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentCoordinationPayload) (v1.Item, error) { + value := v1.Item{ID: items.Identity(turn, "coordination:"+p.ID), TurnID: turn, Type: p.Kind, Status: p.Status, Model: p.Model, ReasoningEffort: p.ReasoningEffort} + if !validNativeIdentity(p.ID) { + return value, ErrInvalidInput + } + if p.Status != "in_progress" && p.Status != "completed" && p.Status != "failed" && p.Status != "incomplete" && p.Kind != "agent_message" { + return value, ErrInvalidInput + } + resolve := func(native string, failedReference bool) (string, error) { + if native == "" { + return q.SubagentRootAgent(ctx, session) + } + row, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: native}) + if errors.Is(err, pgx.ErrNoRows) && failedReference && validNativeIdentity(native) { + return native, nil + } + if err != nil { + return "", err + } + if !row.PublicVisible { + return "", ErrNotFound + } + return uuid.UUID(row.ID.Bytes).String(), nil + } + actor, err := resolve(p.ActorID, false) + if err != nil { + return value, err + } + if actor == "" { + return value, ErrInvalidInput + } + value.SenderAgentID = actor + if p.Text != nil { + value.Content = []v1.ItemContent{{Type: "output_text", Text: p.Text}} + } + switch p.Kind { + case "create_subagent_call": + value.AgentID = actor + case "wait_for_subagents_call": + value.RecipientAgentIDs = []string{} + for _, recipient := range p.Recipients { + id, err := resolve(recipient, p.Status == "failed") + if err != nil { + return value, err + } + value.RecipientAgentIDs = append(value.RecipientAgentIDs, id) + } + case "send_subagent_input_call", "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call", "agent_message": + if len(p.Recipients) != 1 { + return value, ErrInvalidInput + } + value.RecipientAgentID, err = resolve(p.Recipients[0], p.Status == "failed") + if err != nil { + return value, err + } + default: + return value, ErrInvalidInput + } + return value, nil +} +func projectRootCoordination(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, raw json.RawMessage, created pgtype.Timestamptz) error { + var p proto.SubagentCoordinationPayload + if json.Unmarshal(raw, &p) != nil || p.ActorID != "" { + return ErrInvalidInput + } + value, err := coordinationItem(ctx, q, session, uuid.UUID(turn.Bytes).String(), p) + if err != nil { + return err + } + id, _ := parseID(value.ID) + old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + var previous v1.Item + if err == nil { + if err = json.Unmarshal(old.Payload, &previous); err != nil { + return err + } + } + merged, err := items.Merge(items.Update{Item: value}, previous) + if err != nil { + return err + } + payload, err := merged.MarshalStored() + if err != nil { + return err + } + row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: true}) + if err != nil { + return err + } + return recordItemChange(ctx, q, session, row.OutputIndex, previous, merged, nil) +} diff --git a/services/agents-api/internal/store/subagent_dispatch_test.go b/services/core/internal/store/subagent_dispatch_test.go similarity index 96% rename from services/agents-api/internal/store/subagent_dispatch_test.go rename to services/core/internal/store/subagent_dispatch_test.go index 146b8510b..4a2a20024 100644 --- a/services/agents-api/internal/store/subagent_dispatch_test.go +++ b/services/core/internal/store/subagent_dispatch_test.go @@ -6,8 +6,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { diff --git a/services/agents-api/internal/store/subagent_identities.go b/services/core/internal/store/subagent_identities.go similarity index 94% rename from services/agents-api/internal/store/subagent_identities.go rename to services/core/internal/store/subagent_identities.go index b4bfd34ac..ed7d125ca 100644 --- a/services/agents-api/internal/store/subagent_identities.go +++ b/services/core/internal/store/subagent_identities.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/subagent_identities_test.go b/services/core/internal/store/subagent_identities_test.go similarity index 98% rename from services/agents-api/internal/store/subagent_identities_test.go rename to services/core/internal/store/subagent_identities_test.go index d4a2870d6..ab07aeb88 100644 --- a/services/agents-api/internal/store/subagent_identities_test.go +++ b/services/core/internal/store/subagent_identities_test.go @@ -7,8 +7,8 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/subagent_item_projection.go b/services/core/internal/store/subagent_item_projection.go similarity index 94% rename from services/agents-api/internal/store/subagent_item_projection.go rename to services/core/internal/store/subagent_item_projection.go index 5e5e57ee9..f228b7202 100644 --- a/services/agents-api/internal/store/subagent_item_projection.go +++ b/services/core/internal/store/subagent_item_projection.go @@ -5,10 +5,10 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/subagent_item_reads.go b/services/core/internal/store/subagent_item_reads.go similarity index 94% rename from services/agents-api/internal/store/subagent_item_reads.go rename to services/core/internal/store/subagent_item_reads.go index 40bb2ec6f..a309e134f 100644 --- a/services/agents-api/internal/store/subagent_item_reads.go +++ b/services/core/internal/store/subagent_item_reads.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/subagent_lifecycle.go b/services/core/internal/store/subagent_lifecycle.go similarity index 93% rename from services/agents-api/internal/store/subagent_lifecycle.go rename to services/core/internal/store/subagent_lifecycle.go index a6560b342..13273f068 100644 --- a/services/agents-api/internal/store/subagent_lifecycle.go +++ b/services/core/internal/store/subagent_lifecycle.go @@ -6,9 +6,9 @@ import ( "errors" "strings" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/subagent_native_outputs_test.go b/services/core/internal/store/subagent_native_outputs_test.go similarity index 98% rename from services/agents-api/internal/store/subagent_native_outputs_test.go rename to services/core/internal/store/subagent_native_outputs_test.go index 7393e27ca..9b64866d8 100644 --- a/services/agents-api/internal/store/subagent_native_outputs_test.go +++ b/services/core/internal/store/subagent_native_outputs_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/subagent_reads.go b/services/core/internal/store/subagent_reads.go similarity index 97% rename from services/agents-api/internal/store/subagent_reads.go rename to services/core/internal/store/subagent_reads.go index 5cf2fc353..c993afea3 100644 --- a/services/agents-api/internal/store/subagent_reads.go +++ b/services/core/internal/store/subagent_reads.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/subagent_resources_test.go b/services/core/internal/store/subagent_resources_test.go similarity index 98% rename from services/agents-api/internal/store/subagent_resources_test.go rename to services/core/internal/store/subagent_resources_test.go index a635ff6ee..7f2eb9894 100644 --- a/services/agents-api/internal/store/subagent_resources_test.go +++ b/services/core/internal/store/subagent_resources_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/subagent_turn_projection.go b/services/core/internal/store/subagent_turn_projection.go similarity index 92% rename from services/agents-api/internal/store/subagent_turn_projection.go rename to services/core/internal/store/subagent_turn_projection.go index b8a464cc4..cedd2162c 100644 --- a/services/agents-api/internal/store/subagent_turn_projection.go +++ b/services/core/internal/store/subagent_turn_projection.go @@ -7,10 +7,10 @@ import ( "reflect" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/subagent_visibility_public_test.go b/services/core/internal/store/subagent_visibility_public_test.go similarity index 98% rename from services/agents-api/internal/store/subagent_visibility_public_test.go rename to services/core/internal/store/subagent_visibility_public_test.go index eabe625c5..684428e51 100644 --- a/services/agents-api/internal/store/subagent_visibility_public_test.go +++ b/services/core/internal/store/subagent_visibility_public_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/template_composition_public_test.go b/services/core/internal/store/template_composition_public_test.go similarity index 95% rename from services/agents-api/internal/store/template_composition_public_test.go rename to services/core/internal/store/template_composition_public_test.go index a3358d503..3ab0acda0 100644 --- a/services/agents-api/internal/store/template_composition_public_test.go +++ b/services/core/internal/store/template_composition_public_test.go @@ -12,11 +12,11 @@ import ( "testing" "time" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/template_null_selection_public_test.go b/services/core/internal/store/template_null_selection_public_test.go similarity index 95% rename from services/agents-api/internal/store/template_null_selection_public_test.go rename to services/core/internal/store/template_null_selection_public_test.go index a7d13f412..e651b127a 100644 --- a/services/agents-api/internal/store/template_null_selection_public_test.go +++ b/services/core/internal/store/template_null_selection_public_test.go @@ -14,11 +14,11 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/token_usage.go b/services/core/internal/store/token_usage.go similarity index 94% rename from services/agents-api/internal/store/token_usage.go rename to services/core/internal/store/token_usage.go index 9c4414011..c0c5b9613 100644 --- a/services/agents-api/internal/store/token_usage.go +++ b/services/core/internal/store/token_usage.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "fmt" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5/pgtype" "math" "strings" diff --git a/services/agents-api/internal/store/token_usage_integration_test.go b/services/core/internal/store/token_usage_integration_test.go similarity index 98% rename from services/agents-api/internal/store/token_usage_integration_test.go rename to services/core/internal/store/token_usage_integration_test.go index 3304a3e8f..7b41e40a0 100644 --- a/services/agents-api/internal/store/token_usage_integration_test.go +++ b/services/core/internal/store/token_usage_integration_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" "fmt" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "testing" diff --git a/services/agents-api/internal/store/token_usage_test.go b/services/core/internal/store/token_usage_test.go similarity index 100% rename from services/agents-api/internal/store/token_usage_test.go rename to services/core/internal/store/token_usage_test.go diff --git a/services/agents-api/internal/store/tool_policy_native_test.go b/services/core/internal/store/tool_policy_native_test.go similarity index 94% rename from services/agents-api/internal/store/tool_policy_native_test.go rename to services/core/internal/store/tool_policy_native_test.go index ed757166f..8d763354c 100644 --- a/services/agents-api/internal/store/tool_policy_native_test.go +++ b/services/core/internal/store/tool_policy_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/tool_search_native_test.go b/services/core/internal/store/tool_search_native_test.go similarity index 93% rename from services/agents-api/internal/store/tool_search_native_test.go rename to services/core/internal/store/tool_search_native_test.go index 95b3016a8..bc71be7d8 100644 --- a/services/agents-api/internal/store/tool_search_native_test.go +++ b/services/core/internal/store/tool_search_native_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/turn_completion.go b/services/core/internal/store/turn_completion.go similarity index 97% rename from services/agents-api/internal/store/turn_completion.go rename to services/core/internal/store/turn_completion.go index eda77d776..099264eab 100644 --- a/services/agents-api/internal/store/turn_completion.go +++ b/services/core/internal/store/turn_completion.go @@ -6,7 +6,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/turn_events.go b/services/core/internal/store/turn_events.go similarity index 98% rename from services/agents-api/internal/store/turn_events.go rename to services/core/internal/store/turn_events.go index 730f2bfe3..53bdca2e2 100644 --- a/services/agents-api/internal/store/turn_events.go +++ b/services/core/internal/store/turn_events.go @@ -6,7 +6,7 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/turn_events_test.go b/services/core/internal/store/turn_events_test.go similarity index 98% rename from services/agents-api/internal/store/turn_events_test.go rename to services/core/internal/store/turn_events_test.go index ff4739d30..e30a576ff 100644 --- a/services/agents-api/internal/store/turn_events_test.go +++ b/services/core/internal/store/turn_events_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/turn_inputs.go b/services/core/internal/store/turn_inputs.go similarity index 99% rename from services/agents-api/internal/store/turn_inputs.go rename to services/core/internal/store/turn_inputs.go index d2c441114..509978809 100644 --- a/services/agents-api/internal/store/turn_inputs.go +++ b/services/core/internal/store/turn_inputs.go @@ -13,7 +13,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" ) type InputReceipt struct { diff --git a/services/agents-api/internal/store/turn_inputs_test.go b/services/core/internal/store/turn_inputs_test.go similarity index 100% rename from services/agents-api/internal/store/turn_inputs_test.go rename to services/core/internal/store/turn_inputs_test.go diff --git a/services/agents-api/internal/store/turn_reads.go b/services/core/internal/store/turn_reads.go similarity index 95% rename from services/agents-api/internal/store/turn_reads.go rename to services/core/internal/store/turn_reads.go index f2d3288d2..60c27496c 100644 --- a/services/agents-api/internal/store/turn_reads.go +++ b/services/core/internal/store/turn_reads.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/turn_reads_test.go b/services/core/internal/store/turn_reads_test.go similarity index 100% rename from services/agents-api/internal/store/turn_reads_test.go rename to services/core/internal/store/turn_reads_test.go diff --git a/services/core/internal/store/turns.go b/services/core/internal/store/turns.go new file mode 100644 index 000000000..ae5e4e797 --- /dev/null +++ b/services/core/internal/store/turns.go @@ -0,0 +1,176 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +var ErrTurnConflict = errors.New("turn state changed or cancellation was requested") + +const ( + TurnQueued = "queued" + TurnInProgress = "in_progress" + TurnWaiting = "waiting" + TurnCompleted = "completed" + TurnFailed = "failed" + TurnCancelled = "cancelled" +) + +// Turn is a root Turn from the Core work queue and uses its Session's immutable +// execution configuration; Subagent Turns have a native writer and their own +// table. Zero timestamps mean the corresponding event has not occurred. Outcome +// is adapter-owned data, not an upstream response; the API must project +// supported wire types explicitly. +type Turn struct { + ID, SessionID, Status string + CreatedAt time.Time + StartedAt time.Time + CompletedAt time.Time + CancelRequestedAt time.Time + Usage json.RawMessage + Outcome json.RawMessage + // ArtifactCaptureStarted is private Runtime coordination, never a wire field. + ArtifactCaptureStarted bool `json:"-"` +} + +type TurnTransition struct { + ExpectedStatus string + Status string + Outcome json.RawMessage +} + +// GetTurn reads a root Turn. A Subagent Turn ID is not found here, exactly like +// a missing one; GetSubagentTurn reads child Turns. +func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) (Turn, error) { + params, err := publicTurnLookup(tenantID, sessionID, turnID) + if err != nil { + return Turn{}, err + } + row, err := s.queries.GetTurn(ctx, params) + if errors.Is(err, pgx.ErrNoRows) { + return Turn{}, ErrNotFound + } + if err != nil { + return Turn{}, fmt.Errorf("get turn: %w", err) + } + return turnFromRow(row), nil +} + +// TransitionTurn is a compare-and-set for execution callbacks. Once terminal, +// a Turn cannot be reopened or have its outcome overwritten, including by retries. +// A dispatcher must claim queued -> in_progress before sending work to a daemon. +func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID string, input TurnTransition) (Turn, error) { + params, err := turnLookup(tenantID, sessionID, turnID) + if err != nil { + return Turn{}, err + } + if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { + return Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", ErrInvalidInput) + } + outcome, err := canonicalJSONObject(input.Outcome) + if err != nil { + return Turn{}, err + } + if !terminalStatus(input.Status) && string(outcome) != "{}" { + return Turn{}, fmt.Errorf("%w: outcome requires a terminal status", ErrInvalidInput) + } + input.Outcome = outcome + var row sqlc.Turn + err = s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, _ pgtype.UUID) error { + var err error + row, err = transitionTurn(ctx, q, params, input) + return err + }) + if err != nil { + return Turn{}, fmt.Errorf("transition turn: %w", err) + } + return turnFromRow(row), nil +} + +func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnParams, input TurnTransition) (sqlc.Turn, error) { + if _, err := q.GetTurn(ctx, params); errors.Is(err, pgx.ErrNoRows) { + return sqlc.Turn{}, ErrNotFound + } else if err != nil { + return sqlc.Turn{}, err + } + if input.ExpectedStatus == TurnQueued && input.Status == TurnInProgress { + if err := checkRuntimeComputeAdmission(ctx, q, params.SessionID); err != nil { + return sqlc.Turn{}, err + } + } + row, err := q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ + ID: params.ID, SessionID: params.SessionID, ExpectedStatus: input.ExpectedStatus, + NewStatus: input.Status, Outcome: input.Outcome, + }) + if errors.Is(err, pgx.ErrNoRows) { + return sqlc.Turn{}, ErrTurnConflict + } + if err == nil && terminalStatus(row.Status) { + if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { + return sqlc.Turn{}, err + } + row, err = q.GetTurn(ctx, params) + } + if err != nil { + return sqlc.Turn{}, err + } + if err := recordTurnChange(ctx, q, row, false); err != nil { + return sqlc.Turn{}, err + } + return row, nil +} + +func validTransition(from, to string) bool { + switch from { + case TurnQueued: + return to == TurnInProgress || to == TurnFailed || to == TurnCancelled + case TurnInProgress: + return to == TurnWaiting || terminalStatus(to) + case TurnWaiting: + return to == TurnInProgress || terminalStatus(to) + default: + return false + } +} + +func terminalStatus(status string) bool { + return status == TurnCompleted || status == TurnFailed || status == TurnCancelled +} + +func turnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { + var p sqlc.GetTurnParams + var err error + if p.TenantID, err = parseID(tenantID); err != nil { + return p, err + } + if p.SessionID, err = parseID(sessionID); err != nil { + return p, err + } + p.ID, err = parseID(turnID) + return p, err +} + +// publicTurnLookup resolves caller-supplied path identifiers for a Turn or a +// Turn-scoped resource. Unparsable values are indistinguishable from missing ones. +func publicTurnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { + tenant, err := parseID(tenantID) + return sqlc.GetTurnParams{TenantID: tenant, SessionID: parsePathID(sessionID), ID: parsePathID(turnID)}, err +} + +func turnFromRow(row sqlc.Turn) Turn { + return Turn{ + ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), Status: row.Status, + CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, + CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: json.RawMessage(row.Outcome), Usage: json.RawMessage(row.TokenUsage), + ArtifactCaptureStarted: row.ArtifactCaptureStarted, + } +} diff --git a/services/agents-api/internal/store/turns_test.go b/services/core/internal/store/turns_test.go similarity index 100% rename from services/agents-api/internal/store/turns_test.go rename to services/core/internal/store/turns_test.go diff --git a/services/agents-api/internal/store/unified_model_configuration_http_test.go b/services/core/internal/store/unified_model_configuration_http_test.go similarity index 97% rename from services/agents-api/internal/store/unified_model_configuration_http_test.go rename to services/core/internal/store/unified_model_configuration_http_test.go index 06ab98929..6edba0dbe 100644 --- a/services/agents-api/internal/store/unified_model_configuration_http_test.go +++ b/services/core/internal/store/unified_model_configuration_http_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/unstorable_text.go b/services/core/internal/store/unstorable_text.go similarity index 100% rename from services/agents-api/internal/store/unstorable_text.go rename to services/core/internal/store/unstorable_text.go diff --git a/services/agents-api/internal/store/unstorable_text_public_test.go b/services/core/internal/store/unstorable_text_public_test.go similarity index 95% rename from services/agents-api/internal/store/unstorable_text_public_test.go rename to services/core/internal/store/unstorable_text_public_test.go index 3e9b42a60..8b8783038 100644 --- a/services/agents-api/internal/store/unstorable_text_public_test.go +++ b/services/core/internal/store/unstorable_text_public_test.go @@ -10,10 +10,10 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/vault_credentials.go b/services/core/internal/store/vault_credentials.go similarity index 94% rename from services/agents-api/internal/store/vault_credentials.go rename to services/core/internal/store/vault_credentials.go index bbdbb809a..fc94bda14 100644 --- a/services/agents-api/internal/store/vault_credentials.go +++ b/services/core/internal/store/vault_credentials.go @@ -7,9 +7,9 @@ import ( "fmt" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/vault_credentials_delete.go b/services/core/internal/store/vault_credentials_delete.go similarity index 93% rename from services/agents-api/internal/store/vault_credentials_delete.go rename to services/core/internal/store/vault_credentials_delete.go index 28ff85243..4bcccb479 100644 --- a/services/agents-api/internal/store/vault_credentials_delete.go +++ b/services/core/internal/store/vault_credentials_delete.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/vault_credentials_delete_test.go b/services/core/internal/store/vault_credentials_delete_test.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_delete_test.go rename to services/core/internal/store/vault_credentials_delete_test.go index b880ead49..1adcf0a10 100644 --- a/services/agents-api/internal/store/vault_credentials_delete_test.go +++ b/services/core/internal/store/vault_credentials_delete_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/vault_credentials_list.go b/services/core/internal/store/vault_credentials_list.go similarity index 96% rename from services/agents-api/internal/store/vault_credentials_list.go rename to services/core/internal/store/vault_credentials_list.go index 0f2897485..778182f27 100644 --- a/services/agents-api/internal/store/vault_credentials_list.go +++ b/services/core/internal/store/vault_credentials_list.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/services/agents-api/internal/store/vault_credentials_list_test.go b/services/core/internal/store/vault_credentials_list_test.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_list_test.go rename to services/core/internal/store/vault_credentials_list_test.go index ee7c5ae1b..40cbb4871 100644 --- a/services/agents-api/internal/store/vault_credentials_list_test.go +++ b/services/core/internal/store/vault_credentials_list_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/vault_credentials_oauth.go b/services/core/internal/store/vault_credentials_oauth.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_oauth.go rename to services/core/internal/store/vault_credentials_oauth.go index e5a0344d1..c56b0f6d9 100644 --- a/services/agents-api/internal/store/vault_credentials_oauth.go +++ b/services/core/internal/store/vault_credentials_oauth.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/vault_credentials_oauth_test.go b/services/core/internal/store/vault_credentials_oauth_test.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_oauth_test.go rename to services/core/internal/store/vault_credentials_oauth_test.go index b74b3fd30..529336a67 100644 --- a/services/agents-api/internal/store/vault_credentials_oauth_test.go +++ b/services/core/internal/store/vault_credentials_oauth_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/vault_credentials_test.go b/services/core/internal/store/vault_credentials_test.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_test.go rename to services/core/internal/store/vault_credentials_test.go index f36f1edaa..4be312fc9 100644 --- a/services/agents-api/internal/store/vault_credentials_test.go +++ b/services/core/internal/store/vault_credentials_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/vault_credentials_update.go b/services/core/internal/store/vault_credentials_update.go similarity index 93% rename from services/agents-api/internal/store/vault_credentials_update.go rename to services/core/internal/store/vault_credentials_update.go index 34bdb4284..86d4b087e 100644 --- a/services/agents-api/internal/store/vault_credentials_update.go +++ b/services/core/internal/store/vault_credentials_update.go @@ -4,8 +4,8 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/vault_credentials_update_test.go b/services/core/internal/store/vault_credentials_update_test.go similarity index 98% rename from services/agents-api/internal/store/vault_credentials_update_test.go rename to services/core/internal/store/vault_credentials_update_test.go index ff07bb215..efef86bc0 100644 --- a/services/agents-api/internal/store/vault_credentials_update_test.go +++ b/services/core/internal/store/vault_credentials_update_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/services/agents-api/internal/store/vault_status_migration_test.go b/services/core/internal/store/vault_status_migration_test.go similarity index 100% rename from services/agents-api/internal/store/vault_status_migration_test.go rename to services/core/internal/store/vault_status_migration_test.go diff --git a/services/core/internal/store/vaults.go b/services/core/internal/store/vaults.go new file mode 100644 index 000000000..0e5e2937b --- /dev/null +++ b/services/core/internal/store/vaults.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" +) + +// Vault is a tenant-owned resource, independent of Sessions and engine execution. +type Vault struct { + ID string + TenantID string + Name *string + Metadata map[string]string + CreatedAt time.Time +} + +type CreateVaultInput struct { + Name *string + Metadata map[string]string +} + +// CreateVault persists the public layer's normalized name. Each call creates a +// distinct resource; this primitive does not define create retry semantics. +func (s *Store) CreateVault(ctx context.Context, tenantID string, input CreateVaultInput) (Vault, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Vault{}, err + } + var name pgtype.Text + if input.Name != nil { + if !validVaultName(*input.Name) { + return Vault{}, fmt.Errorf("%w: vault name must contain 1–256 UTF-8 bytes", ErrInvalidInput) + } + name = pgtype.Text{String: *input.Name, Valid: true} + } + metadata, err := encodeMetadata(input.Metadata) + if err != nil { + return Vault{}, err + } + var created Vault + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateVault(ctx, sqlc.CreateVaultParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Name: name, Metadata: metadata, + }) + if err != nil { + return err + } + created, err = vaultFromRow(row) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "vault", created.ID, "", AuditResource{Type: "vault", ID: created.ID, ParentID: ""}) + }) + if err != nil { + return Vault{}, fmt.Errorf("create vault: %w", err) + } + return created, nil +} + +func validVaultName(name string) bool { + return len(name) >= 1 && len(name) <= 256 && utf8.ValidString(name) +} + +// GetVault scopes every lookup to the authenticated caller's tenant. +func (s *Store) GetVault(ctx context.Context, tenantID, vaultID string) (Vault, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Vault{}, err + } + id, err := parseID(vaultID) + if err != nil { + return Vault{}, err + } + row, err := s.queries.GetVault(ctx, sqlc.GetVaultParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Vault{}, ErrNotFound + } + if err != nil { + return Vault{}, fmt.Errorf("get vault: %w", err) + } + return vaultFromRow(row) +} + +func vaultFromRow(row sqlc.Vault) (Vault, error) { + vault := Vault{ + ID: uuid.UUID(row.ID.Bytes).String(), TenantID: uuid.UUID(row.TenantID.Bytes).String(), + CreatedAt: row.CreatedAt.Time, + } + if row.Name.Valid { + vault.Name = &row.Name.String + } + if err := json.Unmarshal(row.Metadata, &vault.Metadata); err != nil { + return Vault{}, fmt.Errorf("decode vault metadata: %w", err) + } + return vault, nil +} diff --git a/services/core/internal/store/vaults_delete.go b/services/core/internal/store/vaults_delete.go new file mode 100644 index 000000000..a00d41ba0 --- /dev/null +++ b/services/core/internal/store/vaults_delete.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// DeleteVault relies on the owning foreign key to remove every stored Credential. +func (s *Store) DeleteVault(ctx context.Context, tenantID, vaultID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", ErrNotFound + } + id, err := parseID(vaultID) + if err != nil { + return "", ErrNotFound + } + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + deleted, err := q.DeleteVault(ctx, sqlc.DeleteVaultParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(deleted.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "vault", deletedID, "") + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", errors.New("vault deletion failed") + } + return deletedID, nil +} diff --git a/services/core/internal/store/vaults_delete_test.go b/services/core/internal/store/vaults_delete_test.go new file mode 100644 index 000000000..3a02e5abe --- /dev/null +++ b/services/core/internal/store/vaults_delete_test.go @@ -0,0 +1,201 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { + public, pool := testStore(t) + tenant, foreign := uuid.NewString(), uuid.NewString() + key := bytes.Repeat([]byte{43}, 32) + cipher, err := credentialcrypto.New(key) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + createVault := func() Vault { + t.Helper() + v, err := public.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + return v + } + vault, retained, empty := createVault(), createVault(), createVault() + create := func(id, name, url string) Credential { + t.Helper() + v, err := s.CreateStaticCredential(t.Context(), tenant, id, CreateStaticCredentialInput{Name: name, MCPServerURL: url, Token: name + "-secret"}) + if err != nil { + t.Fatal(err) + } + return v + } + original := create(vault.ID, "original", "https://mcp.example/tools") + attached := []string{vault.ID, retained.ID} + selected, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL}}) + if err != nil || len(selected) != 1 { + t.Fatal("initial unique selection failed", err) + } + configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "model"}, "vault_ids": attached, "mcp_credentials": selected}) + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "retained", Configuration: configuration} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + extra := create(vault.ID, "archived", "https://mcp.example/other") + sibling := create(retained.ID, "sibling", original.MCPServerURL) + if _, err := pool.Exec(t.Context(), "UPDATE vaults SET status='archived' WHERE id=$1", vault.ID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET status='archived' WHERE id=$1", extra.ID); err != nil { + t.Fatal(err) + } + for _, scope := range []struct{ tenant, id string }{{foreign, vault.ID}, {tenant, uuid.NewString()}, {tenant, "invalid"}, {"invalid", vault.ID}} { + if _, err := public.DeleteVault(t.Context(), scope.tenant, scope.id); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign or invalid deletion was accepted", err) + } + } + readOnly := readOnlyResourceStore(t, pool) + _, deletionErr := readOnly.DeleteVault(t.Context(), tenant, vault.ID) + if deletionErr == nil || deletionErr.Error() != "vault deletion failed" { + t.Fatal("failed mutation was accepted or exposed") + } + tx, err := pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(t.Context()) }() + // Verify the database cascade independently inside an explicit transaction. + tenantID, _ := parseID(tenant) + vaultID, _ := parseID(vault.ID) + if _, err := public.queries.WithTx(tx).DeleteVault(t.Context(), sqlc.DeleteVaultParams{TenantID: tenantID, ID: vaultID}); err != nil { + t.Fatal(err) + } + var count int + if err := tx.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("cascade was not visible in the deletion transaction", err) + } + if err := tx.Rollback(t.Context()); err != nil { + t.Fatal(err) + } + if value, err := public.GetVault(t.Context(), tenant, vault.ID); err != nil || !reflect.DeepEqual(value, vault) { + t.Fatal("rollback changed the Vault", err) + } + for _, expected := range []Credential{original, extra} { + if value, err := public.GetCredential(t.Context(), tenant, vault.ID, expected.ID); err != nil || !reflect.DeepEqual(value, expected) { + t.Fatal("rollback changed a child", err) + } + } + if token, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); err != nil || token != "original-secret" { + t.Fatal("rejected deletion changed the stored token") + } + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=decode('00','hex') WHERE vault_id=$1", vault.ID); err != nil { + t.Fatal(err) + } + for _, target := range []Vault{empty, vault} { + if id, err := public.DeleteVault(t.Context(), tenant, target.ID); err != nil || id != target.ID { + t.Fatal("keyless deletion failed", err) + } + } + if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("committed parent or encrypted children remain", err) + } + pool.Close() + public, pool = testStore(t) + cipher, _ = credentialcrypto.New(bytes.Clone(key)) + s = NewWithCredentialCipher(pool, cipher) + for _, target := range []Vault{empty, vault} { + if _, err := public.GetVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Vault reappeared after restart") + } + if _, err := public.DeleteVault(t.Context(), tenant, target.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("repeated deletion did not remain absent") + } + } + for _, child := range []Credential{original, extra} { + if _, err := public.GetCredential(t.Context(), tenant, vault.ID, child.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted child reappeared") + } + if _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, child.ID, UpdateStaticCredentialInput{Token: "replacement"}); !errors.Is(err, ErrNotFound) { + t.Fatal("replacement recreated a deleted child") + } + } + if _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: "late", MCPServerURL: original.MCPServerURL, Token: "late"}); !errors.Is(err, ErrNotFound) { + t.Fatal("new child was admitted under a deleted Vault") + } + if _, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); !errors.Is(err, ErrNotFound) { + t.Fatal("frozen binding reselected a credential in another attached Vault") + } + if _, err := public.ListCredentials(t.Context(), tenant, vault.ID, "", 100, true, []string{"active", "archived"}); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted parent remained listable") + } + if value, err := public.GetVault(t.Context(), tenant, retained.ID); err != nil || !reflect.DeepEqual(value, retained) { + t.Fatal("deletion changed another Vault") + } + if value, err := public.GetCredential(t.Context(), tenant, retained.ID, sibling.ID); err != nil || !reflect.DeepEqual(value, sibling) { + t.Fatal("deletion changed another Vault's credential") + } + if retry, err := public.CreateSession(t.Context(), tenant, input); err != nil || retry.ID != session.ID || !bytes.Equal(retry.Configuration, session.Configuration) { + t.Fatal("deletion changed frozen creation identity", err) + } +} + +func TestVaultDeletionConcurrentChildMutations(t *testing.T) { + public, pool := testStore(t) + tenant := uuid.NewString() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{44}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + for range 8 { + vault, err := s.CreateVault(t.Context(), tenant, CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + input := CreateStaticCredentialInput{Name: "competing", MCPServerURL: "https://mcp.example/tools", Token: "before"} + value, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) + if err != nil { + t.Fatal(err) + } + start, created, updated, removed := make(chan struct{}), make(chan error, 1), make(chan error, 1), make(chan error, 1) + go func() { + <-start + _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, input) + created <- err + }() + go func() { + <-start + _, err := s.UpdateStaticCredential(t.Context(), tenant, vault.ID, value.ID, UpdateStaticCredentialInput{Token: "after"}) + updated <- err + }() + go func() { + <-start + _, err := public.DeleteCredential(t.Context(), tenant, vault.ID, value.ID) + removed <- err + }() + close(start) + _, deleted := public.DeleteVault(t.Context(), tenant, vault.ID) + createErr, updateErr, removeErr := <-created, <-updated, <-removed + var constraint *pgconn.PgError + if createErr != nil && !errors.Is(createErr, ErrNotFound) && !(errors.As(createErr, &constraint) && constraint.Code == "23503") { + t.Fatal("competing creation failed unexpectedly", createErr) + } + if deleted != nil || updateErr != nil && !errors.Is(updateErr, ErrNotFound) || removeErr != nil && !errors.Is(removeErr, ErrNotFound) { + t.Fatal("competing mutation failed unexpectedly", deleted, updateErr, removeErr) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM vaults WHERE id=$1)+(SELECT count(*) FROM vault_credentials WHERE vault_id=$1)", vault.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("concurrent mutation resurrected deleted resources", err) + } + } +} diff --git a/services/core/internal/store/vaults_list.go b/services/core/internal/store/vaults_list.go new file mode 100644 index 000000000..0fff6d29e --- /dev/null +++ b/services/core/internal/store/vaults_list.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type VaultPage struct { + Vaults []Vault + NextCursor string +} + +func (s *Store) ListVaults(ctx context.Context, tenantID, cursor string, limit int, ascending bool, statuses []string) (VaultPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return VaultPage{}, err + } + if limit < 1 || limit > 100 { + return VaultPage{}, fmt.Errorf("%w: internal page size must be 1..100", ErrInvalidInput) + } + if len(statuses) == 0 { + statuses = []string{"active", "archived"} + } + for _, status := range statuses { + if status != "active" && status != "archived" { + return VaultPage{}, fmt.Errorf("%w: invalid Vault status", ErrInvalidInput) + } + } + params := sqlc.ListVaultsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} + if cursor != "" { + after, err := s.GetVault(ctx, tenantID, lookupCursor(cursor)) + if err != nil { + return VaultPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListVaults(ctx, params) + if err != nil { + return VaultPage{}, fmt.Errorf("list vaults: %w", err) + } + page := VaultPage{Vaults: make([]Vault, 0, min(limit, len(rows)))} + if len(rows) > limit { + page.NextCursor = uuid.UUID(rows[limit-1].ID.Bytes).String() + rows = rows[:limit] + } + for _, row := range rows { + vault, err := vaultFromRow(row) + if err != nil { + return VaultPage{}, err + } + page.Vaults = append(page.Vaults, vault) + } + return page, nil +} diff --git a/services/agents-api/internal/store/vaults_list_test.go b/services/core/internal/store/vaults_list_test.go similarity index 100% rename from services/agents-api/internal/store/vaults_list_test.go rename to services/core/internal/store/vaults_list_test.go diff --git a/services/agents-api/internal/store/vaults_test.go b/services/core/internal/store/vaults_test.go similarity index 100% rename from services/agents-api/internal/store/vaults_test.go rename to services/core/internal/store/vaults_test.go diff --git a/services/agents-api/internal/store/whitespace_input_public_test.go b/services/core/internal/store/whitespace_input_public_test.go similarity index 96% rename from services/agents-api/internal/store/whitespace_input_public_test.go rename to services/core/internal/store/whitespace_input_public_test.go index 5dcee7368..b2df5c6a5 100644 --- a/services/agents-api/internal/store/whitespace_input_public_test.go +++ b/services/core/internal/store/whitespace_input_public_test.go @@ -8,11 +8,11 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/device" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/gateway" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) diff --git a/services/agents-api/internal/store/worker_capacity_test.go b/services/core/internal/store/worker_capacity_test.go similarity index 97% rename from services/agents-api/internal/store/worker_capacity_test.go rename to services/core/internal/store/worker_capacity_test.go index ccb884df2..a81840ff8 100644 --- a/services/agents-api/internal/store/worker_capacity_test.go +++ b/services/core/internal/store/worker_capacity_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // Consume the actual controls so capacity rejection is exercised before any diff --git a/services/agents-api/internal/store/worker_input_race_test.go b/services/core/internal/store/worker_input_race_test.go similarity index 94% rename from services/agents-api/internal/store/worker_input_race_test.go rename to services/core/internal/store/worker_input_race_test.go index d983e037b..c4a3953ad 100644 --- a/services/agents-api/internal/store/worker_input_race_test.go +++ b/services/core/internal/store/worker_input_race_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/agents-api/internal/store/worker_lease_loss_test.go b/services/core/internal/store/worker_lease_loss_test.go similarity index 93% rename from services/agents-api/internal/store/worker_lease_loss_test.go rename to services/core/internal/store/worker_lease_loss_test.go index 4eb7c4eab..05f75ff59 100644 --- a/services/agents-api/internal/store/worker_lease_loss_test.go +++ b/services/core/internal/store/worker_lease_loss_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { diff --git a/services/agents-api/internal/store/worker_preparation_failure_test.go b/services/core/internal/store/worker_preparation_failure_test.go similarity index 98% rename from services/agents-api/internal/store/worker_preparation_failure_test.go rename to services/core/internal/store/worker_preparation_failure_test.go index 905ac3849..6ce1549bc 100644 --- a/services/agents-api/internal/store/worker_preparation_failure_test.go +++ b/services/core/internal/store/worker_preparation_failure_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func TestWorkerSettlesConfirmedPreparationFailureAndAcceptsNewInput(t *testing.T) { diff --git a/services/agents-api/internal/store/worker_wakeup_test.go b/services/core/internal/store/worker_wakeup_test.go similarity index 95% rename from services/agents-api/internal/store/worker_wakeup_test.go rename to services/core/internal/store/worker_wakeup_test.go index d53516f3d..64e5478fe 100644 --- a/services/agents-api/internal/store/worker_wakeup_test.go +++ b/services/core/internal/store/worker_wakeup_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/services/core/internal/store/write_audit.go b/services/core/internal/store/write_audit.go new file mode 100644 index 000000000..cffee8cf8 --- /dev/null +++ b/services/core/internal/store/write_audit.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// AuditResource identifies a resource genuinely created by the current transaction. +type AuditResource struct{ Type, ID, ParentID string } + +// ValidAuditResourceType is shared by the write recorder and administrator queries. +func ValidAuditResourceType(value string) bool { + switch value { + case "agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact": + return true + } + return false +} + +func auditText(value string, max int, required bool) bool { + return (!required || value != "") && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max && !strings.ContainsFunc(value, unicode.IsControl) +} + +func validateWriteAuditSource(source writeaudit.Source, tenant string) error { + actual, err := parseID(source.TenantID) + expected, expectedErr := parseID(tenant) + valid := err == nil && expectedErr == nil && actual == expected && + auditText(source.Name, 80, false) && auditText(source.RequestID, 128, true) && auditText(source.TraceID, 128, true) + switch source.Kind { + case "static", "console": + digest := strings.TrimPrefix(source.KeyID, "static:") + valid = valid && strings.HasPrefix(source.KeyID, "static:") && validProjectKeyDigest(digest) && source.Prefix == digest[:min(len(digest), 8)] + case "issued": + _, idErr := parseID(source.KeyID) + valid = valid && idErr == nil && len(source.Prefix) == 11 && strings.HasPrefix(source.Prefix, "pc_") + for _, c := range strings.TrimPrefix(source.Prefix, "pc_") { + valid = valid && (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') + } + default: + valid = false + } + if !valid { + return fmt.Errorf("%w: invalid write audit source", ErrInvalidInput) + } + return nil +} + +// recordWriteAudit must use the caller's business transaction. Internal callers +// without a source stay unattributed; a malformed supplied source fails closed. +func recordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID, parentID string, created ...AuditResource) error { + if _, ok := adminaudit.FromContext(ctx); ok { + return recordAdminMutation(ctx, q, tenant, action, resourceType, resourceID) + } + source, ok := writeaudit.FromContext(ctx) + if !ok { + return nil + } + if err := validateWriteAuditSource(source, tenant); err != nil { + return err + } + switch action { + case "create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version": + default: + return fmt.Errorf("%w: invalid write audit action", ErrInvalidInput) + } + resources := append([]AuditResource{{Type: resourceType, ID: resourceID, ParentID: parentID}}, created...) + for _, resource := range resources { + if !ValidAuditResourceType(resource.Type) || !auditText(resource.ID, 256, true) || !auditText(resource.ParentID, 256, false) { + return fmt.Errorf("%w: invalid write audit resource", ErrInvalidInput) + } + } + tenantID, _ := parseID(tenant) + id, err := q.InsertWriteAuditOperation(ctx, sqlc.InsertWriteAuditOperationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, + KeyID: source.KeyID, KeyName: source.Name, KeyPrefix: source.Prefix, KeyKind: source.Kind, + Action: action, ResourceType: resourceType, ResourceID: resourceID, ParentID: parentID, RequestID: source.RequestID, TraceID: source.TraceID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + for _, resource := range created { + if err := q.InsertWriteAuditOwner(ctx, sqlc.InsertWriteAuditOwnerParams{TenantID: tenantID, ResourceType: resource.Type, ResourceID: resource.ID, ParentID: resource.ParentID, OperationID: id}); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/store/write_audit_queries.go b/services/core/internal/store/write_audit_queries.go similarity index 99% rename from services/agents-api/internal/store/write_audit_queries.go rename to services/core/internal/store/write_audit_queries.go index 674453184..198a17558 100644 --- a/services/agents-api/internal/store/write_audit_queries.go +++ b/services/core/internal/store/write_audit_queries.go @@ -10,7 +10,7 @@ import ( "fmt" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/services/agents-api/internal/store/write_audit_resources_test.go b/services/core/internal/store/write_audit_resources_test.go similarity index 98% rename from services/agents-api/internal/store/write_audit_resources_test.go rename to services/core/internal/store/write_audit_resources_test.go index f91587f17..c9d543b5c 100644 --- a/services/agents-api/internal/store/write_audit_resources_test.go +++ b/services/core/internal/store/write_audit_resources_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" diff --git a/services/core/internal/store/write_audit_test.go b/services/core/internal/store/write_audit_test.go new file mode 100644 index 000000000..03dbb740c --- /dev/null +++ b/services/core/internal/store/write_audit_test.go @@ -0,0 +1,280 @@ +package store + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func auditTestSource(tenant string) writeaudit.Source { + digest := projectKeyDigest(uuid.NewString()) + return writeaudit.Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "test key", Kind: "static", TenantID: tenant, RequestID: uuid.NewString(), TraceID: uuid.NewString()} +} + +func auditTestRecord(t *testing.T, s *Store, source writeaudit.Source, action, kind, id string, created ...AuditResource) { + t.Helper() + ctx := writeaudit.WithSource(t.Context(), source) + if err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + return recordWriteAudit(ctx, s.queries.WithTx(tx), source.TenantID, action, kind, id, "", created...) + }); err != nil { + t.Fatal(err) + } +} + +func TestWriteAuditAtomicCommitAndRollback(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + source := auditTestSource(tenant) + for _, failure := range []string{"", "after_audit", "invalid_source", "database_audit_failure"} { + t.Run(failure, func(t *testing.T) { + id := uuid.NewString() + source.RequestID = uuid.NewString() + if failure == "invalid_source" { + source.TraceID = "" + } else { + source.TraceID = uuid.NewString() + } + ctx := writeaudit.WithSource(t.Context(), source) + err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + tenantUUID, _ := parseID(tenant) + _, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ID: pgtype.UUID{Bytes: uuid.MustParse(id), Valid: true}, TenantID: tenantUUID, Metadata: []byte("{}"), Configuration: []byte("{}")}) + if err != nil { + return err + } + if failure == "database_audit_failure" { + // This transaction-local constraint deliberately rejects the audit insert. + if _, err := tx.Exec(ctx, "ALTER TABLE write_audit_operations ADD CONSTRAINT audit_test_failure CHECK (false) NOT VALID"); err != nil { + return err + } + } + if err := recordWriteAudit(ctx, q, tenant, "create", "agent", id, "", AuditResource{Type: "agent", ID: id}); err != nil { + return err + } + if failure == "after_audit" { + return errors.New("business failure after audit") + } + return nil + }) + if (err != nil) != (failure != "") { + t.Fatalf("commit result: %v", err) + } + _, agentErr := s.GetAgent(t.Context(), tenant, id) + if failure == "" && agentErr != nil || failure != "" && !errors.Is(agentErr, ErrNotFound) { + t.Fatalf("business rollback: %v", agentErr) + } + page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) + if err != nil { + t.Fatal(err) + } + want := 0 + if failure == "" { + want = 1 + } + if len(page.Data) != want { + t.Fatalf("audit count %d want %d", len(page.Data), want) + } + owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) + if err != nil || (owners[0].APIKey != nil) != (failure == "") { + t.Fatalf("ownership rollback: %+v %v", owners, err) + } + }) + } + // A context without authenticated provenance is an intentional internal write. + if err := recordWriteAudit(context.Background(), nil, tenant, "create", "agent", uuid.NewString(), ""); err != nil { + t.Fatal(err) + } +} + +func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { + s, _ := testStore(t) + principal := projectKeyPrincipal() + tenant := principal.TenantID + a := auditTestSource(tenant) + id, implicit := uuid.NewString(), uuid.NewString() + auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: id}, AuditResource{Type: "environment", ID: implicit, ParentID: id}) + // Same request may reach a commit receipt twice but cannot create another owner. + replayID := uuid.NewString() + auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: replayID}) + b := auditTestSource(tenant) + b.Kind = "console" + auditTestRecord(t, s, b, "update", "session", id) + owners, err := s.GetResourceOwners(t.Context(), tenant, "session", []string{replayID, id, id, "historical"}) + if err != nil || len(owners) != 4 || owners[0].APIKey != nil || owners[1].APIKey.ID != a.KeyID || owners[2].APIKey.ID != a.KeyID || owners[3].APIKey != nil { + t.Fatalf("owners %+v: %v", owners, err) + } + implicitOwners, err := s.GetResourceOwners(t.Context(), tenant, "environment", []string{implicit}) + if err != nil || implicitOwners[0].APIKey.ID != a.KeyID { + t.Fatalf("implicit owner: %+v %v", implicitOwners, err) + } + foreign, err := s.GetResourceOwners(t.Context(), uuid.NewString(), "session", []string{id}) + if err != nil || foreign[0].APIKey != nil { + t.Fatalf("foreign owner: %+v %v", foreign, err) + } + page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) + if err != nil || len(page.Data) != 2 || page.Data[0].APIKey.Kind != "console" || page.Data[1].APIKey.ID != a.KeyID { + t.Fatalf("request dedup or key identity: %+v %v", page, err) + } + project := createTestProject(t, s) + issued, err := s.CreateProjectAPIKey(keyAdminContext(t.Context(), project.ID), project.ID, uuid.NewString(), "issued key") + if err != nil { + t.Fatal(err) + } + tenant = project.TenantID + c := auditTestSource(tenant) + c.KeyID, c.Name, c.Prefix, c.Kind = issued.ID, issued.Name, issued.Prefix, "issued" + fileID := "file_" + uuid.NewString() + auditTestRecord(t, s, c, "create", "file", fileID, AuditResource{Type: "file", ID: fileID}) + if err := s.RevokeProjectAPIKey(keyAdminContext(t.Context(), project.ID), project.ID, issued.ID); err != nil { + t.Fatal(err) + } + revoked, err := s.GetResourceOwners(t.Context(), tenant, "file", []string{fileID}) + if err != nil || revoked[0].APIKey.RevokedAt == nil || revoked[0].APIKey.Name != issued.Name { + t.Fatalf("revocation metadata: %+v %v", revoked, err) + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{KeyID: c.KeyID}) + if err != nil || len(page.Data) != 1 || page.Data[0].APIKey.RevokedAt == nil { + t.Fatalf("history revocation: %+v %v", page, err) + } +} + +// The copy operation was removed; its committed provenance must stay readable. +func TestHistoricalAdminCopyProvenance(t *testing.T) { + s, pool := testStore(t) + project := createTestProject(t, s) + auditID, agentID := uuid.NewString(), uuid.NewString() + if _, err := pool.Exec(t.Context(), `INSERT INTO admin_audit_log(id,tenant_id,project_id,admin_credential_id,actor_label,action,resource_type,resource_id,result_ids,request_id,trace_id) + VALUES($1,$2,$3,'digest','admin','copy','agent','source-agent',$4::jsonb,'request','trace')`, auditID, project.TenantID, project.ID, `[{"type":"agent","source_id":"source-agent","target_id":"`+agentID+`"}]`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,audit_id) VALUES($1,'agent',$2,$3)", project.TenantID, agentID, auditID); err != nil { + t.Fatal(err) + } + owners, err := s.GetResourceOwners(t.Context(), project.TenantID, "agent", []string{agentID}) + if err != nil || len(owners) != 1 || owners[0].APIKey != nil || owners[0].Source == nil || *owners[0].Source != "admin_copy" || owners[0].AdminAuditID == nil || *owners[0].AdminAuditID != auditID { + t.Fatalf("historical copy owner: %+v %v", owners, err) + } + page, err := s.ListAdminAudit(t.Context(), AdminAuditFilter{ProjectID: project.ID, Action: "copy"}) + if err != nil || len(page.Data) != 1 || page.Data[0].ID != auditID || !strings.Contains(string(page.Data[0].ResultIDs), agentID) { + t.Fatalf("historical copy audit: %+v %v", page, err) + } +} + +func TestWriteAuditCursorFiltersAndRetention(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + source := auditTestSource(tenant) + agent, err := s.CreateAgent(t.Context(), tenant, CreateAgentInput{Configuration: []byte("{}")}) + if err != nil { + t.Fatal(err) + } + id := agent.ID + auditTestRecord(t, s, source, "create", "agent", id, AuditResource{Type: "agent", ID: id}) + for i := 0; i < 4; i++ { + source.RequestID = uuid.NewString() + auditTestRecord(t, s, source, "update", "agent", id) + } + // Equal timestamps exercise the ID tie breaker, independent of insertion order. + stamp := time.Date(1800, 1, 1, 0, 0, 0, 0, time.UTC) + if _, err := pool.Exec(t.Context(), "UPDATE write_audit_operations SET created_at=$1 WHERE tenant_id=$2", stamp, tenant); err != nil { + t.Fatal(err) + } + first, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2}) + if err != nil || len(first.Data) != 2 || !first.HasMore || first.NextCursor == "" { + t.Fatalf("first %+v %v", first, err) + } + seen := map[string]bool{} + page := first + for { + for _, row := range page.Data { + if seen[row.ID] { + t.Fatal("duplicate cursor item") + } + seen[row.ID] = true + } + if !page.HasMore { + break + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2, After: page.NextCursor}) + if err != nil { + t.Fatal(err) + } + } + if len(seen) != 5 { + t.Fatalf("lost rows %d", len(seen)) + } + for _, filter := range []WriteOperationFilter{{Limit: 2, After: first.NextCursor, KeyID: "different"}, {After: "malformed"}} { + if _, err := s.ListWriteOperations(t.Context(), tenant, filter); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("cursor filter: %v", err) + } + } + if _, err := s.ListWriteOperations(t.Context(), uuid.NewString(), WriteOperationFilter{After: first.NextCursor}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("cross tenant cursor: %v", err) + } + for _, filter := range []WriteOperationFilter{{CreatedBefore: &stamp}, {KeyID: "missing"}, {ResourceType: "file"}, {ResourceID: "missing"}} { + page, err := s.ListWriteOperations(t.Context(), tenant, filter) + if err != nil || len(page.Data) != 0 { + t.Fatalf("filter %+v: %+v %v", filter, page, err) + } + } + inclusive, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{CreatedAfter: &stamp}) + if err != nil || len(inclusive.Data) != 5 { + t.Fatalf("inclusive lower bound: %+v %v", inclusive, err) + } + cutoff := stamp.Add(time.Hour) + n, err := s.DeleteExpiredWriteOperations(t.Context(), cutoff, 2) + if err != nil || n != 2 { + t.Fatalf("bounded retention %d %v", n, err) + } + n, err = s.DeleteExpiredWriteOperations(t.Context(), cutoff, 1000) + if err != nil || n != 2 { + t.Fatalf("remaining retention %d %v", n, err) + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{}) + if err != nil || len(page.Data) != 1 || page.Data[0].Action != "create" { + t.Fatalf("creator retention %+v %v", page, err) + } + // Deleting the business resource cannot cascade through provenance. + if _, err := pool.Exec(t.Context(), "DELETE FROM agents WHERE tenant_id=$1 AND id=$2", tenant, id); err != nil { + t.Fatal(err) + } + owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) + if err != nil || owners[0].APIKey == nil { + t.Fatalf("deleted creator %+v %v", owners, err) + } +} + +func TestWriteAuditSourceValidation(t *testing.T) { + valid := auditTestSource(uuid.NewString()) + for _, field := range []string{"tenant", "key", "prefix", "kind", "request", "trace", "name"} { + source := valid + switch field { + case "tenant": + source.TenantID = uuid.NewString() + case "key": + source.KeyID = "static:abcd" + case "prefix": + source.Prefix = "bad" + case "kind": + source.Kind = "unknown" + case "request": + source.RequestID = "" + case "trace": + source.TraceID = "" + case "name": + source.Name = strings.Repeat("x", 81) + } + ctx := writeaudit.WithSource(t.Context(), source) + if err := recordWriteAudit(ctx, nil, valid.TenantID, "create", "agent", "resource", ""); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("%s accepted: %v", field, err) + } + } +} diff --git a/services/agents-api/internal/writeaudit/context.go b/services/core/internal/writeaudit/context.go similarity index 100% rename from services/agents-api/internal/writeaudit/context.go rename to services/core/internal/writeaudit/context.go diff --git a/services/agents-api/migrations/000001_sessions.sql b/services/core/migrations/000001_sessions.sql similarity index 100% rename from services/agents-api/migrations/000001_sessions.sql rename to services/core/migrations/000001_sessions.sql diff --git a/services/agents-api/migrations/000002_session_configuration.sql b/services/core/migrations/000002_session_configuration.sql similarity index 100% rename from services/agents-api/migrations/000002_session_configuration.sql rename to services/core/migrations/000002_session_configuration.sql diff --git a/services/agents-api/migrations/000003_turns.sql b/services/core/migrations/000003_turns.sql similarity index 100% rename from services/agents-api/migrations/000003_turns.sql rename to services/core/migrations/000003_turns.sql diff --git a/services/agents-api/migrations/000004_input_batches.sql b/services/core/migrations/000004_input_batches.sql similarity index 100% rename from services/agents-api/migrations/000004_input_batches.sql rename to services/core/migrations/000004_input_batches.sql diff --git a/services/agents-api/migrations/000005_devices.sql b/services/core/migrations/000005_devices.sql similarity index 100% rename from services/agents-api/migrations/000005_devices.sql rename to services/core/migrations/000005_devices.sql diff --git a/services/agents-api/migrations/000006_native_sessions.sql b/services/core/migrations/000006_native_sessions.sql similarity index 100% rename from services/agents-api/migrations/000006_native_sessions.sql rename to services/core/migrations/000006_native_sessions.sql diff --git a/services/agents-api/migrations/000007_turn_events.sql b/services/core/migrations/000007_turn_events.sql similarity index 100% rename from services/agents-api/migrations/000007_turn_events.sql rename to services/core/migrations/000007_turn_events.sql diff --git a/services/agents-api/migrations/000008_session_items.sql b/services/core/migrations/000008_session_items.sql similarity index 100% rename from services/agents-api/migrations/000008_session_items.sql rename to services/core/migrations/000008_session_items.sql diff --git a/services/agents-api/migrations/000009_execution_queue.sql b/services/core/migrations/000009_execution_queue.sql similarity index 100% rename from services/agents-api/migrations/000009_execution_queue.sql rename to services/core/migrations/000009_execution_queue.sql diff --git a/services/agents-api/migrations/000010_token_usage.sql b/services/core/migrations/000010_token_usage.sql similarity index 100% rename from services/agents-api/migrations/000010_token_usage.sql rename to services/core/migrations/000010_token_usage.sql diff --git a/services/agents-api/migrations/000011_item_order.sql b/services/core/migrations/000011_item_order.sql similarity index 100% rename from services/agents-api/migrations/000011_item_order.sql rename to services/core/migrations/000011_item_order.sql diff --git a/services/agents-api/migrations/000012_session_events.sql b/services/core/migrations/000012_session_events.sql similarity index 100% rename from services/agents-api/migrations/000012_session_events.sql rename to services/core/migrations/000012_session_events.sql diff --git a/services/agents-api/migrations/000013_function_calls.sql b/services/core/migrations/000013_function_calls.sql similarity index 100% rename from services/agents-api/migrations/000013_function_calls.sql rename to services/core/migrations/000013_function_calls.sql diff --git a/services/agents-api/migrations/000014_function_inputs.sql b/services/core/migrations/000014_function_inputs.sql similarity index 100% rename from services/agents-api/migrations/000014_function_inputs.sql rename to services/core/migrations/000014_function_inputs.sql diff --git a/services/agents-api/migrations/000015_retire_item_backfill.sql b/services/core/migrations/000015_retire_item_backfill.sql similarity index 100% rename from services/agents-api/migrations/000015_retire_item_backfill.sql rename to services/core/migrations/000015_retire_item_backfill.sql diff --git a/services/agents-api/migrations/000016_agents.sql b/services/core/migrations/000016_agents.sql similarity index 100% rename from services/agents-api/migrations/000016_agents.sql rename to services/core/migrations/000016_agents.sql diff --git a/services/agents-api/migrations/000017_session_creation_identity.sql b/services/core/migrations/000017_session_creation_identity.sql similarity index 100% rename from services/agents-api/migrations/000017_session_creation_identity.sql rename to services/core/migrations/000017_session_creation_identity.sql diff --git a/services/agents-api/migrations/000018_session_agent_filter.sql b/services/core/migrations/000018_session_agent_filter.sql similarity index 100% rename from services/agents-api/migrations/000018_session_agent_filter.sql rename to services/core/migrations/000018_session_agent_filter.sql diff --git a/services/agents-api/migrations/000019_session_deletion.sql b/services/core/migrations/000019_session_deletion.sql similarity index 100% rename from services/agents-api/migrations/000019_session_deletion.sql rename to services/core/migrations/000019_session_deletion.sql diff --git a/services/agents-api/migrations/000020_environments.sql b/services/core/migrations/000020_environments.sql similarity index 100% rename from services/agents-api/migrations/000020_environments.sql rename to services/core/migrations/000020_environments.sql diff --git a/services/agents-api/migrations/000021_environment_executor_credentials.sql b/services/core/migrations/000021_environment_executor_credentials.sql similarity index 100% rename from services/agents-api/migrations/000021_environment_executor_credentials.sql rename to services/core/migrations/000021_environment_executor_credentials.sql diff --git a/services/agents-api/migrations/000022_environment_input_reservations.sql b/services/core/migrations/000022_environment_input_reservations.sql similarity index 100% rename from services/agents-api/migrations/000022_environment_input_reservations.sql rename to services/core/migrations/000022_environment_input_reservations.sql diff --git a/services/agents-api/migrations/000023_environment_input_expiry_index.sql b/services/core/migrations/000023_environment_input_expiry_index.sql similarity index 100% rename from services/agents-api/migrations/000023_environment_input_expiry_index.sql rename to services/core/migrations/000023_environment_input_expiry_index.sql diff --git a/services/agents-api/migrations/000024_execution_project_scopes.sql b/services/core/migrations/000024_execution_project_scopes.sql similarity index 100% rename from services/agents-api/migrations/000024_execution_project_scopes.sql rename to services/core/migrations/000024_execution_project_scopes.sql diff --git a/services/agents-api/migrations/000025_session_creators.sql b/services/core/migrations/000025_session_creators.sql similarity index 100% rename from services/agents-api/migrations/000025_session_creators.sql rename to services/core/migrations/000025_session_creators.sql diff --git a/services/agents-api/migrations/000026_executor_principals.sql b/services/core/migrations/000026_executor_principals.sql similarity index 100% rename from services/agents-api/migrations/000026_executor_principals.sql rename to services/core/migrations/000026_executor_principals.sql diff --git a/services/agents-api/migrations/000027_environment_connections.sql b/services/core/migrations/000027_environment_connections.sql similarity index 100% rename from services/agents-api/migrations/000027_environment_connections.sql rename to services/core/migrations/000027_environment_connections.sql diff --git a/services/agents-api/migrations/000028_environment_input_activity.sql b/services/core/migrations/000028_environment_input_activity.sql similarity index 100% rename from services/agents-api/migrations/000028_environment_input_activity.sql rename to services/core/migrations/000028_environment_input_activity.sql diff --git a/services/agents-api/migrations/000029_environment_initial_input.sql b/services/core/migrations/000029_environment_initial_input.sql similarity index 100% rename from services/agents-api/migrations/000029_environment_initial_input.sql rename to services/core/migrations/000029_environment_initial_input.sql diff --git a/services/agents-api/migrations/000030_vaults.sql b/services/core/migrations/000030_vaults.sql similarity index 100% rename from services/agents-api/migrations/000030_vaults.sql rename to services/core/migrations/000030_vaults.sql diff --git a/services/agents-api/migrations/000031_vault_credentials.sql b/services/core/migrations/000031_vault_credentials.sql similarity index 100% rename from services/agents-api/migrations/000031_vault_credentials.sql rename to services/core/migrations/000031_vault_credentials.sql diff --git a/services/agents-api/migrations/000032_vault_status.sql b/services/core/migrations/000032_vault_status.sql similarity index 100% rename from services/agents-api/migrations/000032_vault_status.sql rename to services/core/migrations/000032_vault_status.sql diff --git a/services/agents-api/migrations/000033_credential_status.sql b/services/core/migrations/000033_credential_status.sql similarity index 100% rename from services/agents-api/migrations/000033_credential_status.sql rename to services/core/migrations/000033_credential_status.sql diff --git a/services/agents-api/migrations/000034_subagent_identities.sql b/services/core/migrations/000034_subagent_identities.sql similarity index 100% rename from services/agents-api/migrations/000034_subagent_identities.sql rename to services/core/migrations/000034_subagent_identities.sql diff --git a/services/agents-api/migrations/000035_local_environment_devices.sql b/services/core/migrations/000035_local_environment_devices.sql similarity index 100% rename from services/agents-api/migrations/000035_local_environment_devices.sql rename to services/core/migrations/000035_local_environment_devices.sql diff --git a/services/agents-api/migrations/000036_environment_file_writes.sql b/services/core/migrations/000036_environment_file_writes.sql similarity index 100% rename from services/agents-api/migrations/000036_environment_file_writes.sql rename to services/core/migrations/000036_environment_file_writes.sql diff --git a/services/agents-api/migrations/000037_source_files.sql b/services/core/migrations/000037_source_files.sql similarity index 100% rename from services/agents-api/migrations/000037_source_files.sql rename to services/core/migrations/000037_source_files.sql diff --git a/services/agents-api/migrations/000038_runtime_allocations.sql b/services/core/migrations/000038_runtime_allocations.sql similarity index 100% rename from services/agents-api/migrations/000038_runtime_allocations.sql rename to services/core/migrations/000038_runtime_allocations.sql diff --git a/services/agents-api/migrations/000039_environment_input_failure.sql b/services/core/migrations/000039_environment_input_failure.sql similarity index 100% rename from services/agents-api/migrations/000039_environment_input_failure.sql rename to services/core/migrations/000039_environment_input_failure.sql diff --git a/services/agents-api/migrations/000040_session_artifacts.sql b/services/core/migrations/000040_session_artifacts.sql similarity index 100% rename from services/agents-api/migrations/000040_session_artifacts.sql rename to services/core/migrations/000040_session_artifacts.sql diff --git a/services/agents-api/migrations/000041_source_files_list.sql b/services/core/migrations/000041_source_files_list.sql similarity index 100% rename from services/agents-api/migrations/000041_source_files_list.sql rename to services/core/migrations/000041_source_files_list.sql diff --git a/services/agents-api/migrations/000042_environment_templates.sql b/services/core/migrations/000042_environment_templates.sql similarity index 100% rename from services/agents-api/migrations/000042_environment_templates.sql rename to services/core/migrations/000042_environment_templates.sql diff --git a/services/agents-api/migrations/000043_environment_initial_files.sql b/services/core/migrations/000043_environment_initial_files.sql similarity index 100% rename from services/agents-api/migrations/000043_environment_initial_files.sql rename to services/core/migrations/000043_environment_initial_files.sql diff --git a/services/agents-api/migrations/000044_environment_setup.sql b/services/core/migrations/000044_environment_setup.sql similarity index 100% rename from services/agents-api/migrations/000044_environment_setup.sql rename to services/core/migrations/000044_environment_setup.sql diff --git a/services/agents-api/migrations/000045_environment_skills.sql b/services/core/migrations/000045_environment_skills.sql similarity index 100% rename from services/agents-api/migrations/000045_environment_skills.sql rename to services/core/migrations/000045_environment_skills.sql diff --git a/services/agents-api/migrations/000046_session_model_execution.sql b/services/core/migrations/000046_session_model_execution.sql similarity index 100% rename from services/agents-api/migrations/000046_session_model_execution.sql rename to services/core/migrations/000046_session_model_execution.sql diff --git a/services/agents-api/migrations/000047_environment_network_domains.sql b/services/core/migrations/000047_environment_network_domains.sql similarity index 100% rename from services/agents-api/migrations/000047_environment_network_domains.sql rename to services/core/migrations/000047_environment_network_domains.sql diff --git a/services/agents-api/migrations/000048_skills.sql b/services/core/migrations/000048_skills.sql similarity index 100% rename from services/agents-api/migrations/000048_skills.sql rename to services/core/migrations/000048_skills.sql diff --git a/services/agents-api/migrations/000049_environment_plugins.sql b/services/core/migrations/000049_environment_plugins.sql similarity index 100% rename from services/agents-api/migrations/000049_environment_plugins.sql rename to services/core/migrations/000049_environment_plugins.sql diff --git a/services/agents-api/migrations/000050_runtime_enrollment.sql b/services/core/migrations/000050_runtime_enrollment.sql similarity index 100% rename from services/agents-api/migrations/000050_runtime_enrollment.sql rename to services/core/migrations/000050_runtime_enrollment.sql diff --git a/services/agents-api/migrations/000051_subagent_resources.sql b/services/core/migrations/000051_subagent_resources.sql similarity index 100% rename from services/agents-api/migrations/000051_subagent_resources.sql rename to services/core/migrations/000051_subagent_resources.sql diff --git a/services/agents-api/migrations/000052_runtime_suspension.sql b/services/core/migrations/000052_runtime_suspension.sql similarity index 100% rename from services/agents-api/migrations/000052_runtime_suspension.sql rename to services/core/migrations/000052_runtime_suspension.sql diff --git a/services/agents-api/migrations/000053_runtime_deployment.sql b/services/core/migrations/000053_runtime_deployment.sql similarity index 100% rename from services/agents-api/migrations/000053_runtime_deployment.sql rename to services/core/migrations/000053_runtime_deployment.sql diff --git a/services/agents-api/migrations/000054_oauth_credentials.sql b/services/core/migrations/000054_oauth_credentials.sql similarity index 100% rename from services/agents-api/migrations/000054_oauth_credentials.sql rename to services/core/migrations/000054_oauth_credentials.sql diff --git a/services/agents-api/migrations/000055_skill_default_metadata.sql b/services/core/migrations/000055_skill_default_metadata.sql similarity index 100% rename from services/agents-api/migrations/000055_skill_default_metadata.sql rename to services/core/migrations/000055_skill_default_metadata.sql diff --git a/services/agents-api/migrations/000056_runtime_history.sql b/services/core/migrations/000056_runtime_history.sql similarity index 100% rename from services/agents-api/migrations/000056_runtime_history.sql rename to services/core/migrations/000056_runtime_history.sql diff --git a/services/agents-api/migrations/000057_runtime_nodes.sql b/services/core/migrations/000057_runtime_nodes.sql similarity index 100% rename from services/agents-api/migrations/000057_runtime_nodes.sql rename to services/core/migrations/000057_runtime_nodes.sql diff --git a/services/agents-api/migrations/000058_sandbox_deployment_setup.sql b/services/core/migrations/000058_sandbox_deployment_setup.sql similarity index 100% rename from services/agents-api/migrations/000058_sandbox_deployment_setup.sql rename to services/core/migrations/000058_sandbox_deployment_setup.sql diff --git a/services/agents-api/migrations/000059_agent_model_execution.sql b/services/core/migrations/000059_agent_model_execution.sql similarity index 100% rename from services/agents-api/migrations/000059_agent_model_execution.sql rename to services/core/migrations/000059_agent_model_execution.sql diff --git a/services/agents-api/migrations/000060_session_execution_configuration.sql b/services/core/migrations/000060_session_execution_configuration.sql similarity index 100% rename from services/agents-api/migrations/000060_session_execution_configuration.sql rename to services/core/migrations/000060_session_execution_configuration.sql diff --git a/services/agents-api/migrations/000061_project_api_keys.sql b/services/core/migrations/000061_project_api_keys.sql similarity index 100% rename from services/agents-api/migrations/000061_project_api_keys.sql rename to services/core/migrations/000061_project_api_keys.sql diff --git a/services/agents-api/migrations/000062_environment_failure.sql b/services/core/migrations/000062_environment_failure.sql similarity index 100% rename from services/agents-api/migrations/000062_environment_failure.sql rename to services/core/migrations/000062_environment_failure.sql diff --git a/services/agents-api/migrations/000063_hosted_provider_selection.sql b/services/core/migrations/000063_hosted_provider_selection.sql similarity index 100% rename from services/agents-api/migrations/000063_hosted_provider_selection.sql rename to services/core/migrations/000063_hosted_provider_selection.sql diff --git a/services/agents-api/migrations/000064_write_audit.sql b/services/core/migrations/000064_write_audit.sql similarity index 100% rename from services/agents-api/migrations/000064_write_audit.sql rename to services/core/migrations/000064_write_audit.sql diff --git a/services/agents-api/migrations/000065_environment_file_write_audit.sql b/services/core/migrations/000065_environment_file_write_audit.sql similarity index 100% rename from services/agents-api/migrations/000065_environment_file_write_audit.sql rename to services/core/migrations/000065_environment_file_write_audit.sql diff --git a/services/agents-api/migrations/000066_admin_projects.sql b/services/core/migrations/000066_admin_projects.sql similarity index 100% rename from services/agents-api/migrations/000066_admin_projects.sql rename to services/core/migrations/000066_admin_projects.sql diff --git a/services/agents-api/migrations/000067_admin_asset_copies.sql b/services/core/migrations/000067_admin_asset_copies.sql similarity index 100% rename from services/agents-api/migrations/000067_admin_asset_copies.sql rename to services/core/migrations/000067_admin_asset_copies.sql diff --git a/services/agents-api/migrations/000068_core_metrics_indexes.sql b/services/core/migrations/000068_core_metrics_indexes.sql similarity index 100% rename from services/agents-api/migrations/000068_core_metrics_indexes.sql rename to services/core/migrations/000068_core_metrics_indexes.sql diff --git a/services/agents-api/migrations/000069_hosted_specification.sql b/services/core/migrations/000069_hosted_specification.sql similarity index 100% rename from services/agents-api/migrations/000069_hosted_specification.sql rename to services/core/migrations/000069_hosted_specification.sql diff --git a/services/agents-api/migrations/000070_node_enrollment_capacity.sql b/services/core/migrations/000070_node_enrollment_capacity.sql similarity index 100% rename from services/agents-api/migrations/000070_node_enrollment_capacity.sql rename to services/core/migrations/000070_node_enrollment_capacity.sql diff --git a/services/agents-api/migrations/000071_node_host_history.sql b/services/core/migrations/000071_node_host_history.sql similarity index 100% rename from services/agents-api/migrations/000071_node_host_history.sql rename to services/core/migrations/000071_node_host_history.sql diff --git a/services/agents-api/migrations/000073_e2b_observation_and_template_build.sql b/services/core/migrations/000073_e2b_observation_and_template_build.sql similarity index 100% rename from services/agents-api/migrations/000073_e2b_observation_and_template_build.sql rename to services/core/migrations/000073_e2b_observation_and_template_build.sql diff --git a/services/agents-api/migrations/000074_allocation_phase_time.sql b/services/core/migrations/000074_allocation_phase_time.sql similarity index 100% rename from services/agents-api/migrations/000074_allocation_phase_time.sql rename to services/core/migrations/000074_allocation_phase_time.sql diff --git a/services/agents-api/migrations/000075_public_url_binding.sql b/services/core/migrations/000075_public_url_binding.sql similarity index 100% rename from services/agents-api/migrations/000075_public_url_binding.sql rename to services/core/migrations/000075_public_url_binding.sql diff --git a/services/agents-api/migrations/000076_deployment_model_providers.sql b/services/core/migrations/000076_deployment_model_providers.sql similarity index 100% rename from services/agents-api/migrations/000076_deployment_model_providers.sql rename to services/core/migrations/000076_deployment_model_providers.sql diff --git a/services/agents-api/migrations/000077_node_enrollment_id.sql b/services/core/migrations/000077_node_enrollment_id.sql similarity index 100% rename from services/agents-api/migrations/000077_node_enrollment_id.sql rename to services/core/migrations/000077_node_enrollment_id.sql diff --git a/services/agents-api/migrations/000078_oac_runtime_names.sql b/services/core/migrations/000078_oac_runtime_names.sql similarity index 100% rename from services/agents-api/migrations/000078_oac_runtime_names.sql rename to services/core/migrations/000078_oac_runtime_names.sql diff --git a/services/agents-api/migrations/000079_sandbox_reset.sql b/services/core/migrations/000079_sandbox_reset.sql similarity index 100% rename from services/agents-api/migrations/000079_sandbox_reset.sql rename to services/core/migrations/000079_sandbox_reset.sql diff --git a/services/agents-api/migrations/000080_archived_cancellation_receipts.sql b/services/core/migrations/000080_archived_cancellation_receipts.sql similarity index 100% rename from services/agents-api/migrations/000080_archived_cancellation_receipts.sql rename to services/core/migrations/000080_archived_cancellation_receipts.sql diff --git a/services/agents-api/migrations/000081_sandbox_generations.sql b/services/core/migrations/000081_sandbox_generations.sql similarity index 100% rename from services/agents-api/migrations/000081_sandbox_generations.sql rename to services/core/migrations/000081_sandbox_generations.sql diff --git a/services/agents-api/migrations/000082_node_generations.sql b/services/core/migrations/000082_node_generations.sql similarity index 100% rename from services/agents-api/migrations/000082_node_generations.sql rename to services/core/migrations/000082_node_generations.sql diff --git a/services/agents-api/migrations/000083_session_diagnostics.sql b/services/core/migrations/000083_session_diagnostics.sql similarity index 100% rename from services/agents-api/migrations/000083_session_diagnostics.sql rename to services/core/migrations/000083_session_diagnostics.sql diff --git a/services/agents-api/migrations/000084_deployment_provider_observations.sql b/services/core/migrations/000084_deployment_provider_observations.sql similarity index 100% rename from services/agents-api/migrations/000084_deployment_provider_observations.sql rename to services/core/migrations/000084_deployment_provider_observations.sql diff --git a/services/agents-api/migrations/000085_deployment_model_configuration.sql b/services/core/migrations/000085_deployment_model_configuration.sql similarity index 100% rename from services/agents-api/migrations/000085_deployment_model_configuration.sql rename to services/core/migrations/000085_deployment_model_configuration.sql diff --git a/services/agents-api/migrations/000086_e2b_custom_endpoint.sql b/services/core/migrations/000086_e2b_custom_endpoint.sql similarity index 100% rename from services/agents-api/migrations/000086_e2b_custom_endpoint.sql rename to services/core/migrations/000086_e2b_custom_endpoint.sql diff --git a/services/agents-api/migrations/000087_e2b_generation_endpoints.sql b/services/core/migrations/000087_e2b_generation_endpoints.sql similarity index 100% rename from services/agents-api/migrations/000087_e2b_generation_endpoints.sql rename to services/core/migrations/000087_e2b_generation_endpoints.sql diff --git a/services/agents-api/migrations/000088_environment_input_preparation_failure.sql b/services/core/migrations/000088_environment_input_preparation_failure.sql similarity index 100% rename from services/agents-api/migrations/000088_environment_input_preparation_failure.sql rename to services/core/migrations/000088_environment_input_preparation_failure.sql diff --git a/services/agents-api/migrations/000089_provider_registration.sql b/services/core/migrations/000089_provider_registration.sql similarity index 100% rename from services/agents-api/migrations/000089_provider_registration.sql rename to services/core/migrations/000089_provider_registration.sql diff --git a/services/agents-api/migrations/000090_environment_initialization.sql b/services/core/migrations/000090_environment_initialization.sql similarity index 100% rename from services/agents-api/migrations/000090_environment_initialization.sql rename to services/core/migrations/000090_environment_initialization.sql diff --git a/services/agents-api/migrations/migrations.go b/services/core/migrations/migrations.go similarity index 100% rename from services/agents-api/migrations/migrations.go rename to services/core/migrations/migrations.go diff --git a/services/agents-api/oauth-credentials.md b/services/core/oauth-credentials.md similarity index 100% rename from services/agents-api/oauth-credentials.md rename to services/core/oauth-credentials.md diff --git a/services/agents-api/sqlc.yaml b/services/core/sqlc.yaml similarity index 100% rename from services/agents-api/sqlc.yaml rename to services/core/sqlc.yaml diff --git a/services/agents-api/tests/container_server.py b/services/core/tests/container_server.py similarity index 100% rename from services/agents-api/tests/container_server.py rename to services/core/tests/container_server.py diff --git a/services/agents-api/tests/e2b_native_isolation.py b/services/core/tests/e2b_native_isolation.py similarity index 100% rename from services/agents-api/tests/e2b_native_isolation.py rename to services/core/tests/e2b_native_isolation.py diff --git a/services/agents-api/tests/fixtures/credentials.go b/services/core/tests/fixtures/credentials.go similarity index 100% rename from services/agents-api/tests/fixtures/credentials.go rename to services/core/tests/fixtures/credentials.go diff --git a/services/agents-api/tests/fixtures/items.go b/services/core/tests/fixtures/items.go similarity index 96% rename from services/agents-api/tests/fixtures/items.go rename to services/core/tests/fixtures/items.go index 849b545fd..5a1629972 100644 --- a/services/agents-api/tests/fixtures/items.go +++ b/services/core/tests/fixtures/items.go @@ -3,7 +3,7 @@ package main import ( "context" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) func observeItems(ctx context.Context, s *store.Store, tenant, session, turn, status string) error { diff --git a/services/core/tests/fixtures/main.go b/services/core/tests/fixtures/main.go new file mode 100644 index 000000000..3b1d4c4de --- /dev/null +++ b/services/core/tests/fixtures/main.go @@ -0,0 +1,134 @@ +// Command fixtures prepares internal records for official-client recovery tests. +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type fixture struct { + Tenant string `json:"tenant"` + Session string `json:"session"` + Turns []string `json:"turns"` +} + +func main() { + if err := seed(); err != nil { + os.Stderr.WriteString("Internal fixture setup failed.\n") + os.Exit(1) + } +} + +func seed() error { + if path := os.Getenv("OAC_TEST_PROJECT_IDENTITIES_FIXTURE"); path != "" { + return readProjectIdentities(path) + } + if path := os.Getenv("OAC_TEST_CREDENTIAL_LIST_FIXTURE"); path != "" { + return seedCredentialList(path) + } + if path := os.Getenv("OAC_TEST_VAULT_LIST_FIXTURE"); path != "" { + return seedVaultList(path) + } + path := os.Getenv("OAC_TEST_TURN_FIXTURE") + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f fixture + if err = json.Unmarshal(raw, &f); err != nil { + return err + } + ctx := context.Background() + pool, err := fixturePool(ctx) + if err != nil { + return err + } + defer pool.Close() + s := store.New(pool) + for _, status := range []string{store.TurnCompleted, store.TurnFailed, store.TurnCancelled, store.TurnInProgress} { + receipt, err := s.SubmitMessage(ctx, f.Tenant, f.Session, uuid.NewString(), json.RawMessage(`{"text":"recovery fixture"}`)) + if err != nil { + return err + } + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + return err + } + if err = observeItems(ctx, s, f.Tenant, f.Session, receipt.TurnID, status); err != nil { + return err + } + if status != store.TurnInProgress { + outcome := json.RawMessage(`{"error":"SECRET engine log","done":{"metadata":{"agent_session_id":"PRIVATE"}}}`) + if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnInProgress, Status: status, Outcome: outcome}); err != nil { + return err + } + } + f.Turns = append(f.Turns, receipt.TurnID) + } + raw, err = json.Marshal(f) + if err != nil { + return err + } + return os.WriteFile(path, raw, 0600) +} + +func fixturePool(ctx context.Context) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(os.Getenv("OAC_TEST_DATABASE_URL")) + if err != nil { + return nil, err + } + if !strings.HasPrefix(cfg.ConnConfig.Database, "oac_") || !strings.HasSuffix(cfg.ConnConfig.Database, "_tests") { + return nil, errors.New("dedicated test database required") + } + return pgxpool.NewWithConfig(ctx, cfg) +} + +// readProjectIdentities exports only the scope required by internal test fixtures. +// Projects and credentials are created through the administrator HTTP API. +func readProjectIdentities(path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f struct { + ProjectIDs []string `json:"project_ids"` + Bindings []json.RawMessage `json:"bindings"` + } + if err = json.Unmarshal(raw, &f); err != nil { + return err + } + ctx := context.Background() + pool, err := fixturePool(ctx) + if err != nil { + return err + } + defer pool.Close() + for _, id := range f.ProjectIDs { + projectID, err := uuid.Parse(id) + if err != nil { + return err + } + var binding json.RawMessage + err = pool.QueryRow(ctx, `SELECT json_build_object( + 'tenant_id', p.tenant_id, 'organization_id', s.organization_id, + 'project_id', s.project_id, 'subject_kind', p.subject_kind, + 'subject_id', p.subject_id) + FROM projects p JOIN execution_project_scopes s ON s.tenant_id=p.tenant_id + WHERE p.id=$1`, projectID).Scan(&binding) + if err != nil { + return err + } + f.Bindings = append(f.Bindings, binding) + } + raw, err = json.Marshal(f) + if err != nil { + return err + } + return os.WriteFile(path, raw, 0600) +} diff --git a/services/agents-api/tests/fixtures/vaults.go b/services/core/tests/fixtures/vaults.go similarity index 100% rename from services/agents-api/tests/fixtures/vaults.go rename to services/core/tests/fixtures/vaults.go diff --git a/services/agents-api/tests/image_fixture.py b/services/core/tests/image_fixture.py similarity index 100% rename from services/agents-api/tests/image_fixture.py rename to services/core/tests/image_fixture.py diff --git a/services/agents-api/tests/official_admin_management.py b/services/core/tests/official_admin_management.py similarity index 100% rename from services/agents-api/tests/official_admin_management.py rename to services/core/tests/official_admin_management.py diff --git a/services/agents-api/tests/official_agent_delete.py b/services/core/tests/official_agent_delete.py similarity index 100% rename from services/agents-api/tests/official_agent_delete.py rename to services/core/tests/official_agent_delete.py diff --git a/services/agents-api/tests/official_agent_list.py b/services/core/tests/official_agent_list.py similarity index 100% rename from services/agents-api/tests/official_agent_list.py rename to services/core/tests/official_agent_list.py diff --git a/services/agents-api/tests/official_agent_reference_retry.py b/services/core/tests/official_agent_reference_retry.py similarity index 100% rename from services/agents-api/tests/official_agent_reference_retry.py rename to services/core/tests/official_agent_reference_retry.py diff --git a/services/agents-api/tests/official_agent_references.py b/services/core/tests/official_agent_references.py similarity index 100% rename from services/agents-api/tests/official_agent_references.py rename to services/core/tests/official_agent_references.py diff --git a/services/agents-api/tests/official_agent_update.py b/services/core/tests/official_agent_update.py similarity index 100% rename from services/agents-api/tests/official_agent_update.py rename to services/core/tests/official_agent_update.py diff --git a/services/agents-api/tests/official_agents.py b/services/core/tests/official_agents.py similarity index 100% rename from services/agents-api/tests/official_agents.py rename to services/core/tests/official_agents.py diff --git a/services/agents-api/tests/official_auth.py b/services/core/tests/official_auth.py similarity index 100% rename from services/agents-api/tests/official_auth.py rename to services/core/tests/official_auth.py diff --git a/services/agents-api/tests/official_body.py b/services/core/tests/official_body.py similarity index 100% rename from services/agents-api/tests/official_body.py rename to services/core/tests/official_body.py diff --git a/services/agents-api/tests/official_client.py b/services/core/tests/official_client.py similarity index 99% rename from services/agents-api/tests/official_client.py rename to services/core/tests/official_client.py index 0c9dc730c..d5ad580e0 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/core/tests/official_client.py @@ -102,7 +102,7 @@ def project_bindings(directory): path = Path(directory) / "project-identities.json" path.touch(mode=0o600) path.write_text(json.dumps({"project_ids": project_ids})) - subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + subprocess.run(["go", "run", "./services/core/tests/fixtures"], cwd=root, env=dict(os.environ, OAC_TEST_PROJECT_IDENTITIES_FIXTURE=str(path)), check=True, timeout=120) return json.loads(path.read_text())["bindings"] @@ -246,7 +246,7 @@ def issue_key(project_id, name): assert initial_turn.status == "cancelled" fixture = Path(directory) / "turns.json" fixture.write_text(json.dumps({"tenant": bindings[0]["tenant_id"], "session": turn_session.id})) - subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + subprocess.run(["go", "run", "./services/core/tests/fixtures"], cwd=root, env=dict(os.environ, OAC_TEST_TURN_FIXTURE=str(fixture)), check=True, timeout=120) turn_ids = json.loads(fixture.read_text())["turns"] turns = sessions.turns diff --git a/services/agents-api/tests/official_credential_delete.py b/services/core/tests/official_credential_delete.py similarity index 100% rename from services/agents-api/tests/official_credential_delete.py rename to services/core/tests/official_credential_delete.py diff --git a/services/agents-api/tests/official_credential_list.py b/services/core/tests/official_credential_list.py similarity index 99% rename from services/agents-api/tests/official_credential_list.py rename to services/core/tests/official_credential_list.py index 8521b8915..ffbca380a 100644 --- a/services/agents-api/tests/official_credential_list.py +++ b/services/core/tests/official_credential_list.py @@ -31,7 +31,7 @@ def seed_archived_fixture(root, directory, tenant, vault_id, values): path = Path(directory) / "credential-list.json" path.write_text(json.dumps({"tenant": tenant, "vault": vault_id, "credentials": [value.id for value in values]})) - subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + subprocess.run(["go", "run", "./services/core/tests/fixtures"], cwd=root, env=dict(os.environ, OAC_TEST_CREDENTIAL_LIST_FIXTURE=str(path)), check=True, timeout=120) diff --git a/services/agents-api/tests/official_credential_rotation.py b/services/core/tests/official_credential_rotation.py similarity index 100% rename from services/agents-api/tests/official_credential_rotation.py rename to services/core/tests/official_credential_rotation.py diff --git a/services/agents-api/tests/official_credentials.py b/services/core/tests/official_credentials.py similarity index 100% rename from services/agents-api/tests/official_credentials.py rename to services/core/tests/official_credentials.py diff --git a/services/agents-api/tests/official_diagnostics.py b/services/core/tests/official_diagnostics.py similarity index 100% rename from services/agents-api/tests/official_diagnostics.py rename to services/core/tests/official_diagnostics.py diff --git a/services/agents-api/tests/official_diagnostics_test.py b/services/core/tests/official_diagnostics_test.py similarity index 100% rename from services/agents-api/tests/official_diagnostics_test.py rename to services/core/tests/official_diagnostics_test.py diff --git a/services/agents-api/tests/official_environment_activity.py b/services/core/tests/official_environment_activity.py similarity index 100% rename from services/agents-api/tests/official_environment_activity.py rename to services/core/tests/official_environment_activity.py diff --git a/services/agents-api/tests/official_environment_composition.py b/services/core/tests/official_environment_composition.py similarity index 100% rename from services/agents-api/tests/official_environment_composition.py rename to services/core/tests/official_environment_composition.py diff --git a/services/agents-api/tests/official_environment_events.py b/services/core/tests/official_environment_events.py similarity index 100% rename from services/agents-api/tests/official_environment_events.py rename to services/core/tests/official_environment_events.py diff --git a/services/agents-api/tests/official_environment_files.py b/services/core/tests/official_environment_files.py similarity index 100% rename from services/agents-api/tests/official_environment_files.py rename to services/core/tests/official_environment_files.py diff --git a/services/agents-api/tests/official_environment_files_create.py b/services/core/tests/official_environment_files_create.py similarity index 100% rename from services/agents-api/tests/official_environment_files_create.py rename to services/core/tests/official_environment_files_create.py diff --git a/services/agents-api/tests/official_environment_files_native.py b/services/core/tests/official_environment_files_native.py similarity index 100% rename from services/agents-api/tests/official_environment_files_native.py rename to services/core/tests/official_environment_files_native.py diff --git a/services/agents-api/tests/official_environment_initial_failure.py b/services/core/tests/official_environment_initial_failure.py similarity index 100% rename from services/agents-api/tests/official_environment_initial_failure.py rename to services/core/tests/official_environment_initial_failure.py diff --git a/services/agents-api/tests/official_environment_initial_files.py b/services/core/tests/official_environment_initial_files.py similarity index 100% rename from services/agents-api/tests/official_environment_initial_files.py rename to services/core/tests/official_environment_initial_files.py diff --git a/services/agents-api/tests/official_environment_network.py b/services/core/tests/official_environment_network.py similarity index 100% rename from services/agents-api/tests/official_environment_network.py rename to services/core/tests/official_environment_network.py diff --git a/services/agents-api/tests/official_environment_plugin_mcp.py b/services/core/tests/official_environment_plugin_mcp.py similarity index 100% rename from services/agents-api/tests/official_environment_plugin_mcp.py rename to services/core/tests/official_environment_plugin_mcp.py diff --git a/services/agents-api/tests/official_environment_plugins.py b/services/core/tests/official_environment_plugins.py similarity index 100% rename from services/agents-api/tests/official_environment_plugins.py rename to services/core/tests/official_environment_plugins.py diff --git a/services/agents-api/tests/official_environment_retrieve.py b/services/core/tests/official_environment_retrieve.py similarity index 100% rename from services/agents-api/tests/official_environment_retrieve.py rename to services/core/tests/official_environment_retrieve.py diff --git a/services/agents-api/tests/official_environment_setup.py b/services/core/tests/official_environment_setup.py similarity index 100% rename from services/agents-api/tests/official_environment_setup.py rename to services/core/tests/official_environment_setup.py diff --git a/services/agents-api/tests/official_environment_skill_references.py b/services/core/tests/official_environment_skill_references.py similarity index 100% rename from services/agents-api/tests/official_environment_skill_references.py rename to services/core/tests/official_environment_skill_references.py diff --git a/services/agents-api/tests/official_environment_skills.py b/services/core/tests/official_environment_skills.py similarity index 100% rename from services/agents-api/tests/official_environment_skills.py rename to services/core/tests/official_environment_skills.py diff --git a/services/agents-api/tests/official_environment_templates.py b/services/core/tests/official_environment_templates.py similarity index 100% rename from services/agents-api/tests/official_environment_templates.py rename to services/core/tests/official_environment_templates.py diff --git a/services/agents-api/tests/official_execution.py b/services/core/tests/official_execution.py similarity index 100% rename from services/agents-api/tests/official_execution.py rename to services/core/tests/official_execution.py diff --git a/services/agents-api/tests/official_file_resource_semantics.py b/services/core/tests/official_file_resource_semantics.py similarity index 100% rename from services/agents-api/tests/official_file_resource_semantics.py rename to services/core/tests/official_file_resource_semantics.py diff --git a/services/agents-api/tests/official_function_images.py b/services/core/tests/official_function_images.py similarity index 100% rename from services/agents-api/tests/official_function_images.py rename to services/core/tests/official_function_images.py diff --git a/services/agents-api/tests/official_function_inputs.py b/services/core/tests/official_function_inputs.py similarity index 100% rename from services/agents-api/tests/official_function_inputs.py rename to services/core/tests/official_function_inputs.py diff --git a/services/agents-api/tests/official_function_state.py b/services/core/tests/official_function_state.py similarity index 100% rename from services/agents-api/tests/official_function_state.py rename to services/core/tests/official_function_state.py diff --git a/services/agents-api/tests/official_function_stream.py b/services/core/tests/official_function_stream.py similarity index 100% rename from services/agents-api/tests/official_function_stream.py rename to services/core/tests/official_function_stream.py diff --git a/services/agents-api/tests/official_functions.py b/services/core/tests/official_functions.py similarity index 100% rename from services/agents-api/tests/official_functions.py rename to services/core/tests/official_functions.py diff --git a/services/agents-api/tests/official_hosted_functions_native.py b/services/core/tests/official_hosted_functions_native.py similarity index 100% rename from services/agents-api/tests/official_hosted_functions_native.py rename to services/core/tests/official_hosted_functions_native.py diff --git a/services/agents-api/tests/official_hosted_structured_native.py b/services/core/tests/official_hosted_structured_native.py similarity index 100% rename from services/agents-api/tests/official_hosted_structured_native.py rename to services/core/tests/official_hosted_structured_native.py diff --git a/services/agents-api/tests/official_http_routing.py b/services/core/tests/official_http_routing.py similarity index 100% rename from services/agents-api/tests/official_http_routing.py rename to services/core/tests/official_http_routing.py diff --git a/services/agents-api/tests/official_items.py b/services/core/tests/official_items.py similarity index 100% rename from services/agents-api/tests/official_items.py rename to services/core/tests/official_items.py diff --git a/services/agents-api/tests/official_list_query.py b/services/core/tests/official_list_query.py similarity index 100% rename from services/agents-api/tests/official_list_query.py rename to services/core/tests/official_list_query.py diff --git a/services/agents-api/tests/official_mcode_native.py b/services/core/tests/official_mcode_native.py similarity index 100% rename from services/agents-api/tests/official_mcode_native.py rename to services/core/tests/official_mcode_native.py diff --git a/services/agents-api/tests/official_mcp.py b/services/core/tests/official_mcp.py similarity index 100% rename from services/agents-api/tests/official_mcp.py rename to services/core/tests/official_mcp.py diff --git a/services/agents-api/tests/official_mcp_credentials.py b/services/core/tests/official_mcp_credentials.py similarity index 100% rename from services/agents-api/tests/official_mcp_credentials.py rename to services/core/tests/official_mcp_credentials.py diff --git a/services/agents-api/tests/official_message_images.py b/services/core/tests/official_message_images.py similarity index 100% rename from services/agents-api/tests/official_message_images.py rename to services/core/tests/official_message_images.py diff --git a/services/agents-api/tests/official_model_protocol_native.py b/services/core/tests/official_model_protocol_native.py similarity index 100% rename from services/agents-api/tests/official_model_protocol_native.py rename to services/core/tests/official_model_protocol_native.py diff --git a/services/agents-api/tests/official_oauth_credentials.py b/services/core/tests/official_oauth_credentials.py similarity index 100% rename from services/agents-api/tests/official_oauth_credentials.py rename to services/core/tests/official_oauth_credentials.py diff --git a/services/agents-api/tests/official_pending_actions_native.py b/services/core/tests/official_pending_actions_native.py similarity index 100% rename from services/agents-api/tests/official_pending_actions_native.py rename to services/core/tests/official_pending_actions_native.py diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/core/tests/official_self_hosted_cancel.py similarity index 100% rename from services/agents-api/tests/official_self_hosted_cancel.py rename to services/core/tests/official_self_hosted_cancel.py diff --git a/services/agents-api/tests/official_self_hosted_initial.py b/services/core/tests/official_self_hosted_initial.py similarity index 100% rename from services/agents-api/tests/official_self_hosted_initial.py rename to services/core/tests/official_self_hosted_initial.py diff --git a/services/agents-api/tests/official_session_agent_filter.py b/services/core/tests/official_session_agent_filter.py similarity index 100% rename from services/agents-api/tests/official_session_agent_filter.py rename to services/core/tests/official_session_agent_filter.py diff --git a/services/agents-api/tests/official_session_artifacts.py b/services/core/tests/official_session_artifacts.py similarity index 100% rename from services/agents-api/tests/official_session_artifacts.py rename to services/core/tests/official_session_artifacts.py diff --git a/services/agents-api/tests/official_session_creation_stream.py b/services/core/tests/official_session_creation_stream.py similarity index 100% rename from services/agents-api/tests/official_session_creation_stream.py rename to services/core/tests/official_session_creation_stream.py diff --git a/services/agents-api/tests/official_session_creators.py b/services/core/tests/official_session_creators.py similarity index 100% rename from services/agents-api/tests/official_session_creators.py rename to services/core/tests/official_session_creators.py diff --git a/services/agents-api/tests/official_session_delete.py b/services/core/tests/official_session_delete.py similarity index 100% rename from services/agents-api/tests/official_session_delete.py rename to services/core/tests/official_session_delete.py diff --git a/services/agents-api/tests/official_session_initial_input.py b/services/core/tests/official_session_initial_input.py similarity index 100% rename from services/agents-api/tests/official_session_initial_input.py rename to services/core/tests/official_session_initial_input.py diff --git a/services/agents-api/tests/official_session_metadata.py b/services/core/tests/official_session_metadata.py similarity index 100% rename from services/agents-api/tests/official_session_metadata.py rename to services/core/tests/official_session_metadata.py diff --git a/services/agents-api/tests/official_session_requests.py b/services/core/tests/official_session_requests.py similarity index 100% rename from services/agents-api/tests/official_session_requests.py rename to services/core/tests/official_session_requests.py diff --git a/services/agents-api/tests/official_skill_selectors.py b/services/core/tests/official_skill_selectors.py similarity index 100% rename from services/agents-api/tests/official_skill_selectors.py rename to services/core/tests/official_skill_selectors.py diff --git a/services/agents-api/tests/official_skills.py b/services/core/tests/official_skills.py similarity index 100% rename from services/agents-api/tests/official_skills.py rename to services/core/tests/official_skills.py diff --git a/services/agents-api/tests/official_source_file_errors.py b/services/core/tests/official_source_file_errors.py similarity index 100% rename from services/agents-api/tests/official_source_file_errors.py rename to services/core/tests/official_source_file_errors.py diff --git a/services/agents-api/tests/official_source_file_list.py b/services/core/tests/official_source_file_list.py similarity index 100% rename from services/agents-api/tests/official_source_file_list.py rename to services/core/tests/official_source_file_list.py diff --git a/services/agents-api/tests/official_source_files.py b/services/core/tests/official_source_files.py similarity index 100% rename from services/agents-api/tests/official_source_files.py rename to services/core/tests/official_source_files.py diff --git a/services/agents-api/tests/official_structured_output.py b/services/core/tests/official_structured_output.py similarity index 100% rename from services/agents-api/tests/official_structured_output.py rename to services/core/tests/official_structured_output.py diff --git a/services/agents-api/tests/official_template_composition.py b/services/core/tests/official_template_composition.py similarity index 100% rename from services/agents-api/tests/official_template_composition.py rename to services/core/tests/official_template_composition.py diff --git a/services/agents-api/tests/official_template_null_selection.py b/services/core/tests/official_template_null_selection.py similarity index 100% rename from services/agents-api/tests/official_template_null_selection.py rename to services/core/tests/official_template_null_selection.py diff --git a/services/agents-api/tests/official_tool_policy.py b/services/core/tests/official_tool_policy.py similarity index 100% rename from services/agents-api/tests/official_tool_policy.py rename to services/core/tests/official_tool_policy.py diff --git a/services/agents-api/tests/official_tool_search.py b/services/core/tests/official_tool_search.py similarity index 100% rename from services/agents-api/tests/official_tool_search.py rename to services/core/tests/official_tool_search.py diff --git a/services/agents-api/tests/official_user_runtime.py b/services/core/tests/official_user_runtime.py similarity index 100% rename from services/agents-api/tests/official_user_runtime.py rename to services/core/tests/official_user_runtime.py diff --git a/services/agents-api/tests/official_vault_delete.py b/services/core/tests/official_vault_delete.py similarity index 100% rename from services/agents-api/tests/official_vault_delete.py rename to services/core/tests/official_vault_delete.py diff --git a/services/agents-api/tests/official_vault_list.py b/services/core/tests/official_vault_list.py similarity index 99% rename from services/agents-api/tests/official_vault_list.py rename to services/core/tests/official_vault_list.py index cfd70a9a3..549729fc2 100644 --- a/services/agents-api/tests/official_vault_list.py +++ b/services/core/tests/official_vault_list.py @@ -29,7 +29,7 @@ def seed_archived_fixture(root, directory, tenant, values): # Only this private SQL fixture assigns status; no public archive operation exists. path = Path(directory) / "vault-list.json" path.write_text(json.dumps({"tenant": tenant, "vaults": [value.id for value in values]})) - subprocess.run(["go", "run", "./services/agents-api/tests/fixtures"], cwd=root, + subprocess.run(["go", "run", "./services/core/tests/fixtures"], cwd=root, env=dict(os.environ, OAC_TEST_VAULT_LIST_FIXTURE=str(path)), check=True, timeout=120) diff --git a/services/agents-api/tests/official_vaults.py b/services/core/tests/official_vaults.py similarity index 100% rename from services/agents-api/tests/official_vaults.py rename to services/core/tests/official_vaults.py diff --git a/services/agents-api/tests/official_workspace_images_native.py b/services/core/tests/official_workspace_images_native.py similarity index 100% rename from services/agents-api/tests/official_workspace_images_native.py rename to services/core/tests/official_workspace_images_native.py diff --git a/services/agents-api/tests/requirements.txt b/services/core/tests/requirements.txt similarity index 100% rename from services/agents-api/tests/requirements.txt rename to services/core/tests/requirements.txt diff --git a/services/agents-api/tests/session_cleanup.py b/services/core/tests/session_cleanup.py similarity index 100% rename from services/agents-api/tests/session_cleanup.py rename to services/core/tests/session_cleanup.py diff --git a/services/agents-api/tests/testdata/function-bands.jpg b/services/core/tests/testdata/function-bands.jpg similarity index 100% rename from services/agents-api/tests/testdata/function-bands.jpg rename to services/core/tests/testdata/function-bands.jpg diff --git a/services/agents-api/tools/e2b-provider/Build.Dockerfile b/services/core/tools/e2b-provider/Build.Dockerfile similarity index 78% rename from services/agents-api/tools/e2b-provider/Build.Dockerfile rename to services/core/tools/e2b-provider/Build.Dockerfile index f0c98b4e4..446fa4cd2 100644 --- a/services/agents-api/tools/e2b-provider/Build.Dockerfile +++ b/services/core/tools/e2b-provider/Build.Dockerfile @@ -2,4 +2,4 @@ FROM python:3.12.12-slim-bookworm@sha256:2986c55feb36e6cae00fa1fefb454283e4b33f35e75ff8bdd123b134130be301 RUN apt-get update && apt-get install -y --no-install-recommends binutils \ && rm -rf /var/lib/apt/lists/* -ENTRYPOINT ["python3", "/source/services/agents-api/tools/e2b-provider/build.py"] +ENTRYPOINT ["python3", "/source/services/core/tools/e2b-provider/build.py"] diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md new file mode 100644 index 000000000..d9beba248 --- /dev/null +++ b/services/core/tools/e2b-provider/README.md @@ -0,0 +1,138 @@ +# Managed E2B SDK helper + +Core's Go adapter invokes this one-shot helper for Create, GetInfo, Renew, Kill +and initialization RunCommand. Daily execution and Files remain on the existing +Runtime connection. The helper uses the official E2B Python SDK 2.51.0; it does +not implement provider HTTP, envd RPC, a scheduler or a network service. + +Managed deployment validation uses a separate read-only helper request, bounded +to 30 seconds. The pinned SDK reads the selected template's build inventory and +requires the exact build UUID to be ready with the configured CPU and memory; a +selection without resources adopts the ready build's CPU and memory. It returns +the build's status, CPU, memory and reported disk size for Core to record with +the selection, and creates neither compute nor allocation receipts. + +The console's Core-key-only setup flow uses two more read-only helper requests. +`list_templates` pages the credential's visible templates through the official +`GET /v2/templates` SDK operation; `list_builds` +pages one selected template and returns ready exact build IDs and resources. +Both operations use the same explicit endpoint selectors and a transient API +key. They cap results at 200, never write a receipt, and cannot replace the +deployment write's exact-build validation. +Compatible endpoints must return the E2B SDK 2.51.0 template-list and +template-build response models. The helper does not adapt provider-specific +catalog shapes. + +Runtime observation uses a third read-only request, `observe`, for at most 100 +Runtime observation uses a separate read-only request, `observe`, for at most 100 +allocations. It reads each allocation's sandbox ID from its receipt without the +allocation lock, then runs one `GET /sandboxes/metrics` request and one labelled +listing of this installation's running sandboxes concurrently, within the +caller's deadline; the listing stops once every requested sandbox has appeared. +Only a sandbox that the listing confirms for exactly that allocation is +reported, with the listing's start time. A malformed metrics point makes only +its row unavailable. It never connects to, +renews or changes a sandbox and never writes receipts. See +[Runtime observability](../../../../contracts/agents-api/runtime-observability.md). Actual sandbox information +is checked before writing bootstrap credentials and on subsequent inspection; +resource drift still permits ownership-based cleanup. E2B disk capacity is not +an independently configurable limit. Sandbox inspection does not expose a build +UUID: build provenance comes from the validated immutable create selector. + +Credential replacement uses `verify_credential`, a read-only request with at most +32 Core allocation references. It verifies the fixed build, the SDK's paginated +team-owned template listing, and each settled live receipt against the labelled +sandbox listing. Template and sandbox scans each stop at 100 pages or the caller's +30-second deadline. Missing/unsettled receipts, repeated template cursors and +unconfirmed reads never authorize replacement. No receipt is changed. Core scans +retained generations and allocation pages under one bounded verification context, +then repeats verification while provider calls are fenced before credential commit. +Authentication rejection, ownership mismatch and uncertainty remain distinct fixed +codes. A readable public template is not proof that the key owns it. + +The private JSON boundary has version 1. Requests and credentials enter stdin; +stdout contains one bounded response with sanitized error codes. API keys never +enter arguments, inherited environment or receipts. The process retains its +allocation lock when the Core caller times out, until the bounded SDK operation +returns. Core must serialize lifecycle requests and never replay Create. +The request carries the deployment's explicit API origin and sandbox domain. +Every SDK call uses these selectors after ambient `E2B_*` variables are removed. +Receipts bind an allocation to those selectors; receipts written before this +feature belong to official E2B. Endpoint changes retain earlier generations on their original API and data-plane domain. The candidate credential must verify all retained ownership before an online switch. +Core tracks actual child exit even after caller timeout. Credential fencing +waits for those children without killing them; child exit itself never proves remote +Create settled. Core must serialize lifecycle requests and never replay Create. + +`StateDir` must already exist, be owned by the service user and have mode 0700. +Keep it on durable private storage through Core upgrades/restarts. Its receipts +contain provider connection credentials, ownership identities and one-shot +creation claims, not Core execution state. Losing this directory cannot authorize +recreation or successful cleanup. Do not delete receipts after an uncertain call. + +GetInfo uses SDK metadata/ID reads. SDK `connect` is never used because it can +resume paused compute. The SDK's version-pinned constructor restores clients +from private connection material; this deprecated constructor is intentionally +contained in `sdk.py` and covered by a no-connect/no-create test. An unknown +Create without connection material can be discovered and reclaimed, but cannot +resume bootstrap. Empty lookup cannot settle an unknown Create. Cleanup retains +every matching candidate and confirms exact-ID absence before writing a tombstone. + +`CreateSettled` proves the original Create/bootstrap can no longer mutate. It is +independent of `BootstrapComplete`, which acknowledges the protected initializer's +last step, not enrollment or native readiness. Explicitly settled absence returns +successful Info with `State=absent`; ordinary missing compute has no such proof. +An explicitly rejected Create with a settled receipt and no provider IDs proves +absence without another cloud request. GetInfo and Kill retain that rejection +receipt, so repeated recovery remains possible even when the API key is invalid. +Other receipts still require cloud discovery and ownership checks. +Unconfirmed initialization commands require reclaiming the whole allocation. + +## Build + +The [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) +builds the helper. The artifact contains only regular files and directories, with +executable permissions preserved, including the native `pyqwest` and +`protobuf-py-ext` wheels. `--check` needs no account credential. The installation +owns the durable receipt path independently of this immutable helper payload. + +`deploy/e2b/build-template.py` packages `init.py` and `managed_init.py` with the +qualified Runtime image. Existing templates without these files must be rebuilt. +The shared protected image preparation is used by both managed and self-hosted +startup; their credential formats and one-shot receipts remain separate. + +## Verification + +```sh +python -m unittest discover -s services/core/tools/e2b-provider -p '*_test.py' -v +python -m unittest discover -s services/core/deploy/e2b -p '*_test.py' -v +``` + +The build runs the first suite and validates the relocated helper's `--check` +report. These checks do not establish cloud authentication, native isolation or +real execution. Live acceptance must use owned E2B compute and the same Runtime, +with actual lease renewal, restart/unknown outcome reconciliation and confirmed +cleanup. Initializer and three-harness qualification remain deployment checks. + +## Adapter rules + +Core-managed E2B is a separate hosted deployment choice, using the official pinned +Python SDK through a packaged private helper. Do not restore the retired custom +HTTP/Connect or envd implementation. The adapter implements the same five operations; +cloud allocations use direct placement with no synthetic node, while Runtime execution +and file access keep the shared daemon contract. Its immutable Runtime template build +is deployment configuration, not a public Environment Template. Keep the account API +key encrypted in the database, write-only through admin input and absent from helper +arguments, logs, metadata and receipts. SDK connection materials and attempted-create +receipts belong in the private durable provider state directory; never replace missing +state to make cleanup appear successful. Create runs once. Unknown control-plane +outcomes remain blockers even if a listing is empty. Explicit matching-reference +CreateSettled evidence proves that the original initialization cannot mutate further; +confirmed absent compute may then be released. Ordinary 404 responses do not prove it. +The helper's pinned SDK, dependencies and licenses ship with Core; users do not install +Python packages after selecting E2B in Web. Application-managed self_hosted tooling +remains independent and uses the same Runtime. Qualify each changed path using actual +provider and model execution before claiming acceptance. Run `make check-e2b-provider` +with `OAC_TEST_E2B_SDK_PYTHON` pointing to the pinned SDK environment; the packaged +helper build runs the provider tests as well. The SDK gate also covers the +application-managed launch tests. `make check` covers shared initialization and +managed initialization using only the Python standard library. diff --git a/services/agents-api/tools/e2b-provider/build.py b/services/core/tools/e2b-provider/build.py similarity index 98% rename from services/agents-api/tools/e2b-provider/build.py rename to services/core/tools/e2b-provider/build.py index 65804a685..af070d601 100644 --- a/services/agents-api/tools/e2b-provider/build.py +++ b/services/core/tools/e2b-provider/build.py @@ -11,7 +11,7 @@ import tarfile import tempfile -SOURCE = Path('/source/services/agents-api/tools/e2b-provider') +SOURCE = Path('/source/services/core/tools/e2b-provider') OUTPUT = Path('/output') NAME = 'oac-e2b-provider' BASE = 'python:3.12.12-slim-bookworm@sha256:2986c55feb36e6cae00fa1fefb454283e4b33f35e75ff8bdd123b134130be301' diff --git a/services/agents-api/tools/e2b-provider/credential_test.py b/services/core/tools/e2b-provider/credential_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/credential_test.py rename to services/core/tools/e2b-provider/credential_test.py diff --git a/services/agents-api/tools/e2b-provider/deployment_test.py b/services/core/tools/e2b-provider/deployment_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/deployment_test.py rename to services/core/tools/e2b-provider/deployment_test.py diff --git a/services/agents-api/tools/e2b-provider/discovery_test.py b/services/core/tools/e2b-provider/discovery_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/discovery_test.py rename to services/core/tools/e2b-provider/discovery_test.py diff --git a/services/agents-api/tools/e2b-provider/licenses.py b/services/core/tools/e2b-provider/licenses.py similarity index 100% rename from services/agents-api/tools/e2b-provider/licenses.py rename to services/core/tools/e2b-provider/licenses.py diff --git a/services/agents-api/tools/e2b-provider/main.py b/services/core/tools/e2b-provider/main.py similarity index 100% rename from services/agents-api/tools/e2b-provider/main.py rename to services/core/tools/e2b-provider/main.py diff --git a/services/agents-api/tools/e2b-provider/observation_test.py b/services/core/tools/e2b-provider/observation_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/observation_test.py rename to services/core/tools/e2b-provider/observation_test.py diff --git a/services/agents-api/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py similarity index 100% rename from services/agents-api/tools/e2b-provider/provider.py rename to services/core/tools/e2b-provider/provider.py diff --git a/services/agents-api/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/provider_test.py rename to services/core/tools/e2b-provider/provider_test.py diff --git a/services/agents-api/tools/e2b-provider/requirements.in b/services/core/tools/e2b-provider/requirements.in similarity index 100% rename from services/agents-api/tools/e2b-provider/requirements.in rename to services/core/tools/e2b-provider/requirements.in diff --git a/services/agents-api/tools/e2b-provider/requirements.lock b/services/core/tools/e2b-provider/requirements.lock similarity index 97% rename from services/agents-api/tools/e2b-provider/requirements.lock rename to services/core/tools/e2b-provider/requirements.lock index f58fcfc85..e9454906a 100644 --- a/services/agents-api/tools/e2b-provider/requirements.lock +++ b/services/core/tools/e2b-provider/requirements.lock @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --python-platform x86_64-manylinux_2_28 --python-version 3.12 services/agents-api/tools/e2b-provider/requirements.in -o services/agents-api/tools/e2b-provider/requirements.lock +# uv pip compile --generate-hashes --python-platform x86_64-manylinux_2_28 --python-version 3.12 services/core/tools/e2b-provider/requirements.in -o services/core/tools/e2b-provider/requirements.lock altgraph==0.17.5 \ --hash=sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7 \ --hash=sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597 @@ -33,7 +33,7 @@ dockerfile-parse==2.0.1 \ e2b==2.51.0 \ --hash=sha256:902ee861b30c8ec3e46e7f84db8c993f30695872c7b4490efaad1627ec6e08ba \ --hash=sha256:951d0fd069f017e760b338a435bdb83589b4bfb9574fc5203a7f777e9cb994c2 - # via -r services/agents-api/tools/e2b-provider/requirements.in + # via -r services/core/tools/e2b-provider/requirements.in h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -136,7 +136,7 @@ pyinstaller==6.22.3 \ --hash=sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282 \ --hash=sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0 \ --hash=sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9 - # via -r services/agents-api/tools/e2b-provider/requirements.in + # via -r services/core/tools/e2b-provider/requirements.in pyinstaller-hooks-contrib==2026.7 \ --hash=sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a \ --hash=sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa diff --git a/services/agents-api/tools/e2b-provider/sdk.py b/services/core/tools/e2b-provider/sdk.py similarity index 100% rename from services/agents-api/tools/e2b-provider/sdk.py rename to services/core/tools/e2b-provider/sdk.py diff --git a/services/agents-api/tools/e2b-provider/state.py b/services/core/tools/e2b-provider/state.py similarity index 100% rename from services/agents-api/tools/e2b-provider/state.py rename to services/core/tools/e2b-provider/state.py diff --git a/services/agents-api/tools/e2b-provider/state_test.py b/services/core/tools/e2b-provider/state_test.py similarity index 100% rename from services/agents-api/tools/e2b-provider/state_test.py rename to services/core/tools/e2b-provider/state_test.py diff --git a/services/agents-api/tools/microsandbox-provider/README.md b/services/core/tools/microsandbox-provider/README.md similarity index 100% rename from services/agents-api/tools/microsandbox-provider/README.md rename to services/core/tools/microsandbox-provider/README.md diff --git a/services/agents-api/tools/microsandbox-provider/backend.go b/services/core/tools/microsandbox-provider/backend.go similarity index 95% rename from services/agents-api/tools/microsandbox-provider/backend.go rename to services/core/tools/microsandbox-provider/backend.go index f24cb2e15..00b078d2a 100644 --- a/services/agents-api/tools/microsandbox-provider/backend.go +++ b/services/core/tools/microsandbox-provider/backend.go @@ -6,8 +6,8 @@ import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" ) diff --git a/services/core/tools/microsandbox-provider/bootstrap.go b/services/core/tools/microsandbox-provider/bootstrap.go new file mode 100644 index 000000000..ad1af890a --- /dev/null +++ b/services/core/tools/microsandbox-provider/bootstrap.go @@ -0,0 +1,126 @@ +//go:build linux + +package main + +import ( + "context" + "encoding/json" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + sdk "github.com/superradcompany/microsandbox/sdk/go" +) + +// Runtime reads the shared launch input; its private auth storage stays opaque. +// All credential bytes enter the guest on stdin before any native work is admitted. +const bootstrapScript = ` +import ctypes,json,os,stat,subprocess,sys +b=json.load(sys.stdin) +for p in ['/home/runtime','/home/runtime/.oac','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages','/run/oac']: + os.makedirs(p,mode=0o700,exist_ok=True) + if not stat.S_ISDIR(os.lstat(p).st_mode): raise RuntimeError('invalid bootstrap directory') + os.chmod(p,0o700);os.chown(p,1000,1000) +p='/home/runtime/runtime-bootstrap.json' +fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) +with os.fdopen(fd,'w') as f: + json.dump(b,f) + f.flush();os.fsync(f.fileno());os.fchown(f.fileno(),1000,1000) +if not stat.S_ISDIR(os.lstat('/workspace').st_mode): raise RuntimeError('invalid workspace alias') +libc=ctypes.CDLL(None,use_errno=True) +if libc.mount(b'/environment/workspace',b'/workspace',None,4096,None)!=0: + raise OSError(ctypes.get_errno(),'workspace bind mount failed') +def runtime_user(): + os.setgroups([]);os.setgid(1000);os.setuid(1000) +subprocess.run(['/usr/local/bin/oac-daemon','connect','--profile','default','--bootstrap-file',p,'-b'], + stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, + cwd='/environment/workspace',preexec_fn=runtime_user,check=True) +` + +func (b backend) create(ctx context.Context) (wire.Response, error) { + c := wire.Compute{Name: wire.Name(b.q.Config, b.q.Reference, 0)} + if _, _, e := b.inspect(ctx, c); e == nil { + return wire.Response{}, sandbox.ErrExists + } else if !sdk.IsKind(e, sdk.ErrSandboxNotFound) { + return wire.Response{}, e + } + bootstrap := *b.q.Bootstrap + policy := agentnetwork.Policy{Access: bootstrap.NetworkAccess, AllowedDomains: bootstrap.AllowedDomains} + domains, _ := json.Marshal(policy.Hosts()) + labels := wire.Labels(b.q.Config, b.q.Reference) + labels[bootstrapLabel] = "pending" + live, e := sdk.CreateSandbox(ctx, c.Name, + sdk.WithImage(b.q.Config.Image), sdk.WithMemory(b.q.Config.MemoryMiB), sdk.WithCPUs(b.q.Config.CPUs), + sdk.WithMaxMemory(b.q.Config.MemoryMiB), sdk.WithMaxCPUs(b.q.Config.CPUs), + sdk.WithRootDisk(sdk.RootDisk.Managed(b.q.Config.RootDiskMiB)), sdk.WithUser("1000:1000"), + // A native owned disk keeps workspace and staging on one filesystem. + // Bootstrap creates their directories before starting the daemon. + sdk.WithWorkdir("/"), sdk.WithMounts(map[string]sdk.MountConfig{ + "/environment": sdk.Mount.Owned(sdk.OwnedVolumeOptions{Kind: sdk.VolumeKindDisk, SizeMiB: b.q.Config.EnvironmentDiskMiB}), + }), + sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(b.network()), + sdk.WithEnv(map[string]string{ + "HOME": "/home/runtime", "OAC_RUNTIME_HOME": "/home/runtime/.oac", + "OAC_RUNTIME_ENVIRONMENT_ID": bootstrap.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bootstrap.SessionID, + "OAC_RUNTIME_NETWORK_ACCESS": policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS": string(domains), + "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE": "/run/oac/daemon-suspend.json", + })) + if e != nil { + return wire.Response{}, e + } + defer live.Detach(context.Background()) + c.ID = live.ID() + h, e := sdk.GetSandbox(ctx, c.Name) + if e != nil { + return wire.Response{}, e + } + qualified, e := qualifyCreatedConfiguration(b.q.Config, b.q.Reference, c, h.ID(), string(h.Status()), h.ConfigJSON()) + if e != nil { + return qualified, e + } + data, e := bootstrap.RuntimeConnection().Marshal() + if e != nil { + return wire.Response{}, e + } + initialization, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + result, e := runCommand(initialization, live, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", bootstrapScript}, Stdin: data}, "0:0") + if e != nil { + return wire.Response{}, e + } + if result.ExitCode != 0 { + return wire.Response{}, sandbox.ErrCommandUnconfirmed + } + // Persist the final bootstrap receipt without restarting the live guest. + // v0.7.2 cannot update active labels; ownership reads persisted config. + _, e = live.Modify(ctx, sdk.ModifyOptions{Labels: map[string]string{bootstrapLabel: "complete"}, Policy: sdk.ModificationPolicyNextStart}) + if e != nil { + return wire.Response{}, e + } + _, state, e := b.inspect(ctx, c) + if e == nil && !state.BootstrapComplete { + return wire.Response{}, sandbox.ErrCommandUnconfirmed + } + return wire.Response{State: &state}, e +} + +// Only the initial post-Create inspection uses this proof. Native creation has +// returned successfully, and no bootstrap command has started. Ordinary inspect +// and unknown Create outcomes cannot acquire settlement through this path. +func qualifyCreatedConfiguration(config wire.Config, ref sandbox.Reference, created wire.Compute, actualID, status, raw string) (wire.Response, error) { + if created.ID == "" { + return wire.Response{}, sandbox.ErrOwnership + } + state, err := qualifyCompute(config, ref, created, actualID, status, raw) + if err != nil { + return wire.Response{}, err + } + if state.Status == "" || state.Status == "absent" || state.BootstrapComplete { + return wire.Response{}, sandbox.ErrOwnership + } + if err := qualifyConfiguration(config, created, raw, false); err != nil { + return wire.Response{State: &state, CreateSettled: true}, err + } + return wire.Response{State: &state}, nil +} diff --git a/services/core/tools/microsandbox-provider/command.go b/services/core/tools/microsandbox-provider/command.go new file mode 100644 index 000000000..7cdb7f157 --- /dev/null +++ b/services/core/tools/microsandbox-provider/command.go @@ -0,0 +1,105 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "errors" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + sdk "github.com/superradcompany/microsandbox/sdk/go" +) + +func runCommand(ctx context.Context, live *sdk.Sandbox, c sandbox.Command, user string) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + deadline, ok := ctx.Deadline() + if !ok || wire.ValidateCommand(c) != nil { + return result, sandbox.ErrInvalid + } + timeout := time.Until(deadline) + if timeout <= 0 { + return result, sandbox.ErrCommandUnconfirmed + } + options := []sdk.ExecOption{sdk.WithExecUser(user), sdk.WithExecCwd(c.Directory), sdk.WithExecTimeout(timeout), sdk.WithExecStdinPipe()} + handle, e := live.ExecStream(ctx, c.Args[0], c.Args[1:], options...) + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + defer handle.Close() + sink := handle.TakeStdin() + if sink == nil { + return result, sandbox.ErrCommandUnconfirmed + } + // Write and receive concurrently to avoid full-pipe deadlocks. + written := make(chan error, 1) + go func() { + data := c.Stdin + for len(data) > 0 { + n := len(data) + if n > 65536 { + n = 65536 + } + count, err := sink.WriteCtx(ctx, data[:n]) + if err != nil { + written <- err + return + } + if count != n { + written <- errors.New("short command input") + return + } + data = data[n:] + } + written <- sink.Close() + }() + return collectCommand(ctx, handle.Recv, written) +} + +func collectCommand(ctx context.Context, receive func(context.Context) (*sdk.ExecEvent, error), written <-chan error) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + var stdout, stderr bytes.Buffer + exited := false + for { + event, err := receive(ctx) + if err != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, err) + } + switch event.Kind { + case sdk.ExecEventStdout: + if stdout.Len()+len(event.Data) > wire.MaxOutputBytes { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + _, _ = stdout.Write(event.Data) + case sdk.ExecEventStderr: + if stderr.Len()+len(event.Data) > wire.MaxOutputBytes { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + _, _ = stderr.Write(event.Data) + case sdk.ExecEventExited: + if exited { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + exited = true + result.ExitCode = event.ExitCode + case sdk.ExecEventStdinError, sdk.ExecEventFailed: + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + case sdk.ExecEventDone: + if !exited { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + select { + case err := <-written: + if err != nil { + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + result.Stdout, result.Stderr = stdout.String(), stderr.String() + return result, nil + case <-ctx.Done(): + return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed + } + } + } +} diff --git a/services/agents-api/tools/microsandbox-provider/command_test.go b/services/core/tools/microsandbox-provider/command_test.go similarity index 91% rename from services/agents-api/tools/microsandbox-provider/command_test.go rename to services/core/tools/microsandbox-provider/command_test.go index 90bd28b18..696385c32 100644 --- a/services/agents-api/tools/microsandbox-provider/command_test.go +++ b/services/core/tools/microsandbox-provider/command_test.go @@ -5,8 +5,8 @@ package main import ( "context" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" "strings" "testing" diff --git a/services/core/tools/microsandbox-provider/creation_settlement_test.go b/services/core/tools/microsandbox-provider/creation_settlement_test.go new file mode 100644 index 000000000..492daf304 --- /dev/null +++ b/services/core/tools/microsandbox-provider/creation_settlement_test.go @@ -0,0 +1,58 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" +) + +func TestCreatedConfigurationRejectionRequiresExactOwnedCompute(t *testing.T) { + for _, name := range []string{"matching", "resource drift", "image drift", "foreign ID", "foreign labels", "missing created ID", "bootstrap complete", "absent", "malformed"} { + t.Run(name, func(t *testing.T) { + config, actual := deploymentFixture() + ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"} + created := wire.Compute{Name: wire.Name(config, ref, 0), ID: "local:created"} + labels := wire.Labels(config, ref) + labels[bootstrapLabel] = "pending" + actual["labels"] = labels + actualID, status := created.ID, "running" + switch name { + case "resource drift": + actual["resources"].(map[string]any)["cpus"] = 1 + case "image drift": + actual["image"] = "runtime:latest" + case "foreign ID": + actualID = "local:foreign" + case "foreign labels": + actual["labels"] = map[string]string{} + case "missing created ID": + created.ID = "" + case "bootstrap complete": + labels[bootstrapLabel] = "complete" + case "absent": + status = "absent" + } + raw, _ := json.Marshal(actual) + if name == "malformed" { + raw = []byte(`{"labels":`) + } + result, err := qualifyCreatedConfiguration(config, ref, created, actualID, status, string(raw)) + settled := name == "resource drift" || name == "image drift" + if result.CreateSettled != settled || (err == nil) != (name == "matching") { + t.Fatal("configuration result changed creation proof", result, err) + } + if settled { + if !errors.Is(err, sandbox.ErrInvalid) || result.State == nil || result.State.Compute != created || result.State.BootstrapComplete { + t.Fatal("rejection did not preserve exact unbootstrapped compute", result, err) + } + } else if name != "matching" && result.State != nil { + t.Fatal("unverified compute returned a receipt", result) + } + }) + } +} diff --git a/services/core/tools/microsandbox-provider/deployment.go b/services/core/tools/microsandbox-provider/deployment.go new file mode 100644 index 000000000..4149539b1 --- /dev/null +++ b/services/core/tools/microsandbox-provider/deployment.go @@ -0,0 +1,70 @@ +//go:build linux + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + sdk "github.com/superradcompany/microsandbox/sdk/go" +) + +const resourceProofLabel = "io.oac.resource-proof" + +func resourceProof(config wire.Config) string { + raw, _ := json.Marshal(struct { + Image string + Resources sandbox.Resources + }{config.Image, sandbox.Resources{CPUs: uint32(config.CPUs), MemoryMiB: config.MemoryMiB, + RootDiskMiB: config.RootDiskMiB, EnvironmentDiskMiB: config.EnvironmentDiskMiB}}) + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +// The SDK decodes native CPU/memory/rootfs configuration. Its v0.7.2 projection +// omits mounts, so only the owned disk inventory is projected separately here. +// Restored roots have no configured size: a verified full snapshot supplies that +// proof, retained as a label only after inspecting the restored target. +func qualifyConfiguration(config wire.Config, compute wire.Compute, raw string, inheritedRoot bool) error { + var actual sdk.SandboxConfig + if json.Unmarshal([]byte(raw), &actual) != nil || actual.Image != config.Image || + actual.CPUs != config.CPUs || actual.MaxCPUs != config.CPUs || + actual.MemoryMiB != config.MemoryMiB || actual.MaxMemoryMiB != config.MemoryMiB || + actual.RootDisk == nil || actual.RootDisk.Kind() != sdk.RootDiskKindManaged { + return sandbox.ErrInvalid + } + if actual.RootDisk.SizeMiB != config.RootDiskMiB { + if actual.RootDisk.SizeMiB != 0 || compute.RestoredFrom == nil || + (!inheritedRoot && actual.Labels[resourceProofLabel] != resourceProof(config)) { + return sandbox.ErrInvalid + } + } + var inventory struct { + Mounts []struct { + Type string `json:"type"` + Guest string `json:"guest"` + Storage struct { + Kind string `json:"kind"` + CapacityMiB uint32 `json:"capacity_mib"` + } `json:"storage"` + } `json:"mounts"` + } + if json.Unmarshal([]byte(raw), &inventory) != nil || len(inventory.Mounts) != 1 { + return sandbox.ErrInvalid + } + mount := inventory.Mounts[0] + if mount.Type != "Owned" || mount.Guest != "/environment" || mount.Storage.Kind != "disk" || mount.Storage.CapacityMiB != config.EnvironmentDiskMiB { + return sandbox.ErrInvalid + } + return nil +} + +func qualifySnapshotResources(config wire.Config, labels map[string]string) error { + if labels[resourceProofLabel] != resourceProof(config) { + return sandbox.ErrInvalid + } + return nil +} diff --git a/services/core/tools/microsandbox-provider/deployment_test.go b/services/core/tools/microsandbox-provider/deployment_test.go new file mode 100644 index 000000000..ecd2f0b9f --- /dev/null +++ b/services/core/tools/microsandbox-provider/deployment_test.go @@ -0,0 +1,92 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" +) + +func deploymentFixture() (wire.Config, map[string]any) { + config := wire.Config{Image: "runtime@sha256:" + strings.Repeat("a", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 4096} + actual := map[string]any{ + "image": map[string]any{"Oci": map[string]any{"reference": config.Image, "root_disk": map[string]any{"kind": "managed", "size_mib": 8192}}}, + "resources": map[string]any{"cpus": 2, "max_cpus": 2, "memory_mib": 2048, "max_memory_mib": 2048}, + "mounts": []any{map[string]any{"type": "Owned", "guest": "/environment", "storage": map[string]any{"kind": "disk", "capacity_mib": 4096}}}, + } + return config, actual +} + +func TestNativeDeploymentConfigurationRejectsDrift(t *testing.T) { + for _, field := range []string{"match", "cpus", "max_cpus", "memory_mib", "max_memory_mib", "image", "root", "environment", "extra_mount", "missing_mount"} { + t.Run(field, func(t *testing.T) { + config, actual := deploymentFixture() + switch field { + case "cpus", "max_cpus", "memory_mib", "max_memory_mib": + actual["resources"].(map[string]any)[field] = 1 + case "image": + actual["image"] = "runtime:latest" + case "root": + actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = 16384 + case "environment": + actual["mounts"].([]any)[0].(map[string]any)["storage"].(map[string]any)["capacity_mib"] = 8192 + case "extra_mount": + actual["mounts"] = append(actual["mounts"].([]any), map[string]any{"type": "Bind", "guest": "/other"}) + case "missing_mount": + delete(actual, "mounts") + } + raw, _ := json.Marshal(actual) + err := qualifyConfiguration(config, wire.Compute{}, string(raw), false) + if (field == "match" && err != nil) || (field != "match" && !errors.Is(err, sandbox.ErrInvalid)) { + t.Fatalf("configuration=%s error=%v", raw, err) + } + }) + } +} + +func TestRestoredRootRequiresVerifiedInheritedProof(t *testing.T) { + config, actual := deploymentFixture() + actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = nil + compute := wire.Compute{RestoredFrom: &wire.SnapshotIdentity{ID: "verified-parent"}} + for _, proof := range []string{"", "foreign", resourceProof(config)} { + actual["labels"] = map[string]string{resourceProofLabel: proof} + raw, _ := json.Marshal(actual) + err := qualifyConfiguration(config, compute, string(raw), false) + if (proof == resourceProof(config)) != (err == nil) { + t.Fatalf("proof=%s error=%v", proof, err) + } + if err = qualifyConfiguration(config, wire.Compute{}, string(raw), true); err == nil { + t.Fatal("initial root accepted missing size") + } + if err = qualifyConfiguration(config, compute, string(raw), true); err != nil { + t.Fatal("verified snapshot cannot qualify restored root", err) + } + } +} + +func TestSnapshotProofBindsResourcesWithoutChangingCleanupOwnership(t *testing.T) { + config, actual := deploymentFixture() + ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"} + labels := wire.Labels(config, ref) + labels[resourceProofLabel] = resourceProof(config) + if err := qualifySnapshotResources(config, labels); err != nil { + t.Fatal(err) + } + config.RootDiskMiB++ + if err := qualifySnapshotResources(config, labels); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("snapshot accepted different bound", err) + } + actual["labels"] = labels + raw, _ := json.Marshal(actual) + if _, err := qualifyCompute(config, ref, wire.Compute{ID: "owned"}, "owned", "running", string(raw)); err != nil { + t.Fatal("resource drift changed cleanup ownership", err) + } + if err := qualifySnapshotResources(config, nil); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("missing snapshot proof accepted", err) + } +} diff --git a/services/core/tools/microsandbox-provider/go.mod b/services/core/tools/microsandbox-provider/go.mod new file mode 100644 index 000000000..25d0423c5 --- /dev/null +++ b/services/core/tools/microsandbox-provider/go.mod @@ -0,0 +1,16 @@ +module github.com/MiniMax-AI/OpenAgentCore/services/core/tools/microsandbox-provider + +go 1.26.8 + +require ( + github.com/MiniMax-AI/OpenAgentCore v0.0.0 + github.com/superradcompany/microsandbox/sdk/go v0.7.2 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + golang.org/x/sync v0.22.0 // indirect +) + +// The helper ships from this repository; the SDK itself is a released module. +replace github.com/MiniMax-AI/OpenAgentCore => ../../../.. diff --git a/services/agents-api/tools/microsandbox-provider/go.sum b/services/core/tools/microsandbox-provider/go.sum similarity index 100% rename from services/agents-api/tools/microsandbox-provider/go.sum rename to services/core/tools/microsandbox-provider/go.sum diff --git a/services/agents-api/tools/microsandbox-provider/installation_test.go b/services/core/tools/microsandbox-provider/installation_test.go similarity index 100% rename from services/agents-api/tools/microsandbox-provider/installation_test.go rename to services/core/tools/microsandbox-provider/installation_test.go diff --git a/services/agents-api/tools/microsandbox-provider/lock_test.go b/services/core/tools/microsandbox-provider/lock_test.go similarity index 92% rename from services/agents-api/tools/microsandbox-provider/lock_test.go rename to services/core/tools/microsandbox-provider/lock_test.go index 0a76639cc..7102a5bdf 100644 --- a/services/agents-api/tools/microsandbox-provider/lock_test.go +++ b/services/core/tools/microsandbox-provider/lock_test.go @@ -5,7 +5,7 @@ package main import ( "context" "errors" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" "os" "path/filepath" "testing" diff --git a/services/core/tools/microsandbox-provider/main.go b/services/core/tools/microsandbox-provider/main.go new file mode 100644 index 000000000..8d9adb29a --- /dev/null +++ b/services/core/tools/microsandbox-provider/main.go @@ -0,0 +1,266 @@ +//go:build linux + +// The helper performs one finite Provider operation. Only Core owns durable +// lifecycle intent, allocation generations, scheduling and recovery policy. +package main + +import ( + "context" + "crypto/sha256" + "debug/elf" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "runtime/debug" + "strings" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" + sdk "github.com/superradcompany/microsandbox/sdk/go" +) + +func main() { + // The inherited node generation lease covers this helper's actual lifetime. + // Set CLOEXEC before SDK initialization or any possible subprocess spawn so + // long-lived VMs and host daemons cannot inherit a local helper reference. + if os.Getenv("OAC_NODE_GENERATION_LEASE_FD") == "3" { + syscall.CloseOnExec(3) + } + response := serve(os.Stdin) + // Errors and upstream diagnostics can contain secrets: expose only stable codes. + _ = json.NewEncoder(os.Stdout).Encode(response) +} +func serve(input io.Reader) wire.Response { + out := wire.Response{Version: wire.ProtocolVersion} + decoder := json.NewDecoder(io.LimitReader(input, wire.MaxRequestBytes+1)) + decoder.DisallowUnknownFields() + var q wire.Request + if decoder.Decode(&q) != nil || wire.ValidateRequest(q) != nil { + out.ErrorCode = "invalid" + return out + } + var extra any + if decoder.Decode(&extra) != io.EOF { + out.ErrorCode = "invalid" + return out + } + if err := checkInstallation(q.Config); err != nil { + out.ErrorCode = "unconfirmed" + return out + } + release, err := allocationLock(q) + if err != nil { + out.ErrorCode = "unconfirmed" + return out + } + defer release() + // Lifecycle calls deliberately retain the flock until the SDK has settled. + // Cancelling an FFI wait does not prove the underlying operation stopped. + b := backend{q: q} + out, err = b.run(context.Background()) + out.Version = wire.ProtocolVersion + if err != nil { + out.ErrorCode = code(err) + } + return out +} +func code(err error) string { + switch { + case errors.Is(err, sandbox.ErrInvalid): + return "invalid" + case errors.Is(err, sandbox.ErrOwnership), sdk.IsKind(err, sdk.ErrSandboxReplaced): + return "ownership" + case errors.Is(err, sandbox.ErrExists), sdk.IsKind(err, sdk.ErrSandboxAlreadyExists): + return "exists" + case errors.Is(err, sandbox.ErrNotFound), sdk.IsKind(err, sdk.ErrSandboxNotFound): + return "not_found" + case errors.Is(err, sandbox.ErrCommandUnconfirmed): + return "command_unconfirmed" + case errors.Is(err, runtimeobs.ErrUnavailable), sdk.IsKind(err, sdk.ErrMetricsDisabled), sdk.IsKind(err, sdk.ErrMetricsUnavailable): + return "metrics_unavailable" + default: + return "unconfirmed" + } +} +func allocationLock(q wire.Request) (func(), error) { + dir := filepath.Join(q.Config.RuntimeHome, "oac-locks") + if e := os.MkdirAll(dir, 0700); e != nil { + return nil, e + } + st, e := os.Lstat(dir) + if e != nil { + return nil, e + } + if !st.IsDir() || st.Mode().Perm() != 0700 { + return nil, sandbox.ErrOwnership + } + name := filepath.Join(dir, wire.Name(q.Config, q.Reference, 0)+".lock") + fd, e := syscall.Open(name, syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) + if e != nil { + return nil, e + } + f := os.NewFile(uintptr(fd), name) + for { + if !time.Now().Before(q.Deadline) { + f.Close() + return nil, context.DeadlineExceeded + } + e = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) + if e == nil { + return func() { _ = syscall.Flock(fd, syscall.LOCK_UN); _ = f.Close() }, nil + } + if e != syscall.EWOULDBLOCK && e != syscall.EAGAIN { + f.Close() + return nil, e + } + time.Sleep(20 * time.Millisecond) + } +} +func checkInstallation(c wire.Config) error { + build, ok := debug.ReadBuildInfo() + if !ok { + return sandbox.ErrInvalid + } + matched := false + for _, d := range build.Deps { + if d.Path == "github.com/superradcompany/microsandbox/sdk/go" { + matched = d.Version == wire.SDKVersion && d.Replace == nil + } + } + if !matched { + return sandbox.ErrInvalid + } + for _, v := range []struct{ path, hash string }{{c.RuntimePath, c.RuntimeSHA256}, {c.FirmwarePath, c.FirmwareSHA256}} { + f, e := os.Open(v.path) + if e != nil { + return e + } + h := sha256.New() + _, e = io.Copy(h, f) + _ = f.Close() + if e != nil || hex.EncodeToString(h.Sum(nil)) != v.hash { + return sandbox.ErrInvalid + } + } + if e := runtimeVersion(c.RuntimePath); e != nil { + return e + } + if e := os.MkdirAll(c.RuntimeHome, 0700); e != nil { + return e + } + st, e := os.Lstat(c.RuntimeHome) + if e != nil { + return e + } + if !st.IsDir() || st.Mode().Perm() != 0700 { + return sandbox.ErrOwnership + } + // Explicit paths and a local backend avoid ambient cloud/profile selection. + for _, v := range wire.HelperEnvironment(nil, c) { + key, value, ok := cutEnv(v) + if ok { + if e := os.Setenv(key, value); e != nil { + return e + } + } + } + configPath, e := isolatedConfig(c.RuntimeHome) + if e != nil { + return e + } + if e := os.Setenv("MSB_CONFIG_PATH", configPath); e != nil { + return e + } + runtime, e := sdk.ResolveRuntime(sdk.RuntimeConfig{Home: c.RuntimeHome, MSBPath: c.RuntimePath, LibkrunfwPath: c.FirmwarePath}) + if e != nil { + return e + } + if runtime.MSBPath != c.RuntimePath || runtime.LibkrunfwPath != c.FirmwarePath { + return sandbox.ErrOwnership + } + selected, e := sdk.DefaultBackendInfo() + if e != nil { + return e + } + if selected.Kind != sdk.BackendLocal { + return sandbox.ErrOwnership + } + return nil +} +func cutEnv(s string) (string, string, bool) { + for i := range s { + if s[i] == '=' { + return s[:i], s[i+1:], true + } + } + return "", "", false +} + +func runtimeVersion(path string) error { + binary, e := elf.Open(path) + if e != nil { + return e + } + defer binary.Close() + section := binary.Section(".msbver") + if section == nil { + return sandbox.ErrInvalid + } + version, e := section.Data() + if e != nil { + return e + } + if strings.TrimRight(string(version), "\x00") != strings.TrimPrefix(wire.SDKVersion, "v") { + return sandbox.ErrInvalid + } + return nil +} + +// An empty JSON object prevents ambient SDK profiles without invalid empty input. +func isolatedConfig(home string) (string, error) { + path := filepath.Join(home, "oac-sdk-config.json") + fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW, 0600) + if err == nil { + f := os.NewFile(uintptr(fd), path) + _, err = f.WriteString("{}\n") + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return "", err + } + if closeErr != nil { + return "", closeErr + } + } else if !os.IsExist(err) { + return "", err + } + fd, err = syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return "", err + } + f := os.NewFile(uintptr(fd), path) + defer f.Close() + st, err := f.Stat() + if err != nil { + return "", err + } + if !st.Mode().IsRegular() || st.Mode().Perm() != 0600 { + return "", sandbox.ErrOwnership + } + data, err := io.ReadAll(io.LimitReader(f, 4)) + if err != nil { + return "", err + } + if string(data) != "{}\n" { + return "", sandbox.ErrOwnership + } + return path, nil +} diff --git a/services/agents-api/tools/microsandbox-provider/metrics.go b/services/core/tools/microsandbox-provider/metrics.go similarity index 94% rename from services/agents-api/tools/microsandbox-provider/metrics.go rename to services/core/tools/microsandbox-provider/metrics.go index f0795959f..d9998cc5f 100644 --- a/services/agents-api/tools/microsandbox-provider/metrics.go +++ b/services/core/tools/microsandbox-provider/metrics.go @@ -12,9 +12,9 @@ import ( "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" ) func (b backend) metrics(ctx context.Context, c wire.Compute) (*wire.Metrics, error) { diff --git a/services/agents-api/tools/microsandbox-provider/metrics_test.go b/services/core/tools/microsandbox-provider/metrics_test.go similarity index 96% rename from services/agents-api/tools/microsandbox-provider/metrics_test.go rename to services/core/tools/microsandbox-provider/metrics_test.go index 8b361f08e..92fcf83b4 100644 --- a/services/agents-api/tools/microsandbox-provider/metrics_test.go +++ b/services/core/tools/microsandbox-provider/metrics_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" ) func nativeMetrics(timestamp, uptime string, cpu uint64) []byte { diff --git a/services/agents-api/tools/microsandbox-provider/observed.go b/services/core/tools/microsandbox-provider/observed.go similarity index 88% rename from services/agents-api/tools/microsandbox-provider/observed.go rename to services/core/tools/microsandbox-provider/observed.go index 93e7698a7..008afa85d 100644 --- a/services/agents-api/tools/microsandbox-provider/observed.go +++ b/services/core/tools/microsandbox-provider/observed.go @@ -4,8 +4,8 @@ package main import ( "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" ) func qualifyCompute(deployment wire.Config, reference sandbox.Reference, c wire.Compute, actualID, status, raw string) (wire.State, error) { diff --git a/services/agents-api/tools/microsandbox-provider/observed_test.go b/services/core/tools/microsandbox-provider/observed_test.go similarity index 94% rename from services/agents-api/tools/microsandbox-provider/observed_test.go rename to services/core/tools/microsandbox-provider/observed_test.go index 5fdad8ff3..1a23c205f 100644 --- a/services/agents-api/tools/microsandbox-provider/observed_test.go +++ b/services/core/tools/microsandbox-provider/observed_test.go @@ -5,8 +5,8 @@ package main import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" "testing" ) diff --git a/services/agents-api/tools/microsandbox-provider/restore_completion.go b/services/core/tools/microsandbox-provider/restore_completion.go similarity index 94% rename from services/agents-api/tools/microsandbox-provider/restore_completion.go rename to services/core/tools/microsandbox-provider/restore_completion.go index b72f205ed..be4a590b0 100644 --- a/services/agents-api/tools/microsandbox-provider/restore_completion.go +++ b/services/core/tools/microsandbox-provider/restore_completion.go @@ -6,8 +6,8 @@ import ( "context" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" ) diff --git a/services/agents-api/tools/microsandbox-provider/restore_completion_test.go b/services/core/tools/microsandbox-provider/restore_completion_test.go similarity index 97% rename from services/agents-api/tools/microsandbox-provider/restore_completion_test.go rename to services/core/tools/microsandbox-provider/restore_completion_test.go index 792c49457..38cb5c4a3 100644 --- a/services/agents-api/tools/microsandbox-provider/restore_completion_test.go +++ b/services/core/tools/microsandbox-provider/restore_completion_test.go @@ -7,8 +7,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" ) func TestRestoreCompletionRecoversOnlyDerivedProof(t *testing.T) { diff --git a/services/agents-api/tools/microsandbox-provider/snapshot.go b/services/core/tools/microsandbox-provider/snapshot.go similarity index 97% rename from services/agents-api/tools/microsandbox-provider/snapshot.go rename to services/core/tools/microsandbox-provider/snapshot.go index 549a9c628..75916c102 100644 --- a/services/agents-api/tools/microsandbox-provider/snapshot.go +++ b/services/core/tools/microsandbox-provider/snapshot.go @@ -7,8 +7,8 @@ import ( "fmt" "strconv" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" ) diff --git a/services/agents-api/tools/microsandbox-provider/snapshot_observation_test.go b/services/core/tools/microsandbox-provider/snapshot_observation_test.go similarity index 95% rename from services/agents-api/tools/microsandbox-provider/snapshot_observation_test.go rename to services/core/tools/microsandbox-provider/snapshot_observation_test.go index cb12a59ba..88ea625db 100644 --- a/services/agents-api/tools/microsandbox-provider/snapshot_observation_test.go +++ b/services/core/tools/microsandbox-provider/snapshot_observation_test.go @@ -7,8 +7,8 @@ import ( "errors" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" ) diff --git a/services/core-console/Dockerfile b/services/web/Dockerfile similarity index 100% rename from services/core-console/Dockerfile rename to services/web/Dockerfile diff --git a/services/core-console/api_keys_test.go b/services/web/api_keys_test.go similarity index 100% rename from services/core-console/api_keys_test.go rename to services/web/api_keys_test.go diff --git a/services/core-console/auth.go b/services/web/auth.go similarity index 100% rename from services/core-console/auth.go rename to services/web/auth.go diff --git a/services/core-console/auth_test.go b/services/web/auth_test.go similarity index 100% rename from services/core-console/auth_test.go rename to services/web/auth_test.go diff --git a/services/web/config.go b/services/web/config.go new file mode 100644 index 000000000..7e0680e52 --- /dev/null +++ b/services/web/config.go @@ -0,0 +1,133 @@ +package main + +import ( + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +type config struct { + addr, origin, dist string + coreKey, nodePayloadDir string + upstream *url.URL + installationSocket string + bootstrap bool +} + +func loadConfig() (config, error) { + var problems, renamed []string + for _, setting := range [][2]string{ + {"CORE_CONSOLE_ADDR", "OAC_WEB_ADDR"}, + {"CORE_CONSOLE_ORIGIN", "OAC_WEB_ORIGIN"}, + {"CORE_CONSOLE_DIST", "OAC_WEB_DIST"}, + {"CORE_CONSOLE_UPSTREAM", "OAC_WEB_UPSTREAM"}, + {"CORE_CONSOLE_CORE_KEY_FILE", "OAC_WEB_CORE_KEY_FILE"}, + {"CORE_CONSOLE_NODE_PAYLOAD_DIR", "OAC_WEB_NODE_PAYLOAD_DIR"}, + } { + if _, present := os.LookupEnv(setting[0]); present { + renamed = append(renamed, setting[0]+" → "+setting[1]) + } + } + renamed = append(renamed, log.RenamedEnvironment()...) + if len(renamed) > 0 { + problems = append(problems, "OpenAgentCore renamed these Web settings; set the new names and remove the old ones: "+strings.Join(renamed, ", ")) + } + if _, present := os.LookupEnv("CORE_CONSOLE_ADMIN_TOKEN_FILE"); present { + problems = append(problems, "CORE_CONSOLE_ADMIN_TOKEN_FILE was renamed; set OAC_WEB_CORE_KEY_FILE to the Core key file instead") + } + for _, retired := range []string{"CORE_CONSOLE_AUTH_MODE", "CORE_CONSOLE_STATE_DIR", "CORE_CONSOLE_PASSWORD_FILE"} { + if _, present := os.LookupEnv(retired); present { + problems = append(problems, retired+" is retired; Web signs in with the Core key only, so remove this setting") + } + } + if len(problems) > 0 { + return config{}, errors.New(strings.Join(problems, "; ")) + } + c := config{ + addr: envDefault("OAC_WEB_ADDR", ":8080"), + origin: envDefault("OAC_WEB_ORIGIN", "http://127.0.0.1:8080"), + dist: envDefault("OAC_WEB_DIST", "/www"), + } + origin, err := serverURL(c.origin) + if err != nil || origin.Path != "" { + return config{}, errors.New("OAC_WEB_ORIGIN must be an HTTP(S) origin without a path") + } + c.upstream, err = serverURL(envDefault("OAC_WEB_UPSTREAM", "http://core:8091")) + if err != nil { + return config{}, errors.New("OAC_WEB_UPSTREAM must be an HTTP(S) server URL without credentials, query or path") + } + if !filepath.IsAbs(c.dist) { + return config{}, errors.New("OAC_WEB_DIST must be absolute") + } + c.coreKey, err = readSecret(envDefault("OAC_WEB_CORE_KEY_FILE", "/admin/core.key")) + if err != nil { + return config{}, errors.New("OAC_WEB_CORE_KEY_FILE must name a private regular file containing the Core key") + } + if utf8.RuneCountInString(c.coreKey) < minimumCoreKeyLength { + return config{}, errors.New("the Core key in OAC_WEB_CORE_KEY_FILE must have at least 32 characters") + } + c.nodePayloadDir = os.Getenv("OAC_WEB_NODE_PAYLOAD_DIR") + if c.nodePayloadDir != "" && !filepath.IsAbs(c.nodePayloadDir) { + return config{}, errors.New("OAC_WEB_NODE_PAYLOAD_DIR must be absolute") + } + c.installationSocket = os.Getenv("OAC_WEB_INSTALLATION_SOCKET") + if c.installationSocket != "" && !filepath.IsAbs(c.installationSocket) { + return config{}, errors.New("OAC_WEB_INSTALLATION_SOCKET must be absolute") + } + bootstrap := envDefault("OAC_WEB_BOOTSTRAP", "0") + if bootstrap != "0" && bootstrap != "1" { + return config{}, errors.New("OAC_WEB_BOOTSTRAP must be 0 or 1") + } + c.bootstrap = bootstrap == "1" + if c.bootstrap && (origin.Scheme != "http" || c.installationSocket == "") { + return config{}, errors.New("HTTP bootstrap requires installation management and an HTTP origin") + } + return c, nil +} + +func envDefault(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return fallback +} + +func serverURL(value string) (*url.URL, error) { + u, err := url.Parse(value) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || + u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + strings.ContainsAny(value, "?#") || (u.Path != "" && u.Path != "/") || u.RawPath != "" { + return nil, errors.New("invalid server URL") + } + return u, nil +} + +func readSecret(name string) (string, error) { + if !filepath.IsAbs(name) { + return "", errors.New("secret path must be absolute") + } + f, err := os.Open(name) + if err != nil { + return "", err + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return "", errors.New("secret file must be private and regular") + } + data, err := io.ReadAll(io.LimitReader(f, 4097)) + if err != nil || len(data) > 4096 { + return "", errors.New("invalid secret size") + } + value := strings.TrimSpace(string(data)) + if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\x00\r\n") { + return "", errors.New("invalid secret") + } + return value, nil +} diff --git a/services/core-console/config_test.go b/services/web/config_test.go similarity index 100% rename from services/core-console/config_test.go rename to services/web/config_test.go diff --git a/services/core-console/core_errors.go b/services/web/core_errors.go similarity index 100% rename from services/core-console/core_errors.go rename to services/web/core_errors.go diff --git a/services/core-console/core_errors_test.go b/services/web/core_errors_test.go similarity index 100% rename from services/core-console/core_errors_test.go rename to services/web/core_errors_test.go diff --git a/services/core-console/core_routes.go b/services/web/core_routes.go similarity index 100% rename from services/core-console/core_routes.go rename to services/web/core_routes.go diff --git a/services/core-console/distribution_test.go b/services/web/distribution_test.go similarity index 100% rename from services/core-console/distribution_test.go rename to services/web/distribution_test.go diff --git a/services/core-console/installation.go b/services/web/installation.go similarity index 100% rename from services/core-console/installation.go rename to services/web/installation.go diff --git a/services/core-console/installation_test.go b/services/web/installation_test.go similarity index 100% rename from services/core-console/installation_test.go rename to services/web/installation_test.go diff --git a/services/web/main.go b/services/web/main.go new file mode 100644 index 000000000..044e4e786 --- /dev/null +++ b/services/web/main.go @@ -0,0 +1,53 @@ +// Command oac-web serves the Core Web build and its authenticated API proxy. +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" +) + +func main() { + if err := run(); err != nil { + log.Bg().Error("Core console stopped", "error", err) + os.Exit(1) + } +} + +func run() error { + log.Init(log.ConfigFromEnv()) + c, err := loadConfig() + if err != nil { + return err + } + handler, err := newConsole(c) + if err != nil { + return err + } + defer handler.Close() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + server := &http.Server{Addr: c.addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} + done := make(chan error, 1) + go func() { done <- server.ListenAndServe() }() + select { + case err := <-done: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return errors.New("console listener failed") + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + return server.Close() + } + return nil + } +} diff --git a/services/core-console/node_artifacts.go b/services/web/node_artifacts.go similarity index 100% rename from services/core-console/node_artifacts.go rename to services/web/node_artifacts.go diff --git a/services/core-console/node_artifacts_test.go b/services/web/node_artifacts_test.go similarity index 96% rename from services/core-console/node_artifacts_test.go rename to services/web/node_artifacts_test.go index 9fc9b21ed..8dc245484 100644 --- a/services/core-console/node_artifacts_test.go +++ b/services/web/node_artifacts_test.go @@ -26,7 +26,7 @@ func TestOnlineNodeArtifactsRedirectWithoutLocalPayload(t *testing.T) { } } sum := sha256.Sum256([]byte("node-data")) - manifest := nodeManifest{SourceCommit: revision, ArtifactBaseURL: "https://github.com/MiniMax-AI/parsar-core/releases/download/v1.2.3", Artifacts: map[string]nodeArtifact{}} + manifest := nodeManifest{SourceCommit: revision, ArtifactBaseURL: "https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v1.2.3", Artifacts: map[string]nodeArtifact{}} for logical := range optionalPayloadFiles { manifest.Artifacts[logical] = nodeArtifact{Filename: "oac-" + revision + "-" + strings.ReplaceAll(logical, "/", "-"), Size: 9, SHA256: hex.EncodeToString(sum[:])} } diff --git a/services/core-console/node_installation.go b/services/web/node_installation.go similarity index 100% rename from services/core-console/node_installation.go rename to services/web/node_installation.go diff --git a/services/core-console/node_installation_test.go b/services/web/node_installation_test.go similarity index 100% rename from services/core-console/node_installation_test.go rename to services/web/node_installation_test.go diff --git a/services/core-console/project_proxy_test.go b/services/web/project_proxy_test.go similarity index 100% rename from services/core-console/project_proxy_test.go rename to services/web/project_proxy_test.go diff --git a/services/core-console/sandbox_admin_test.go b/services/web/sandbox_admin_test.go similarity index 100% rename from services/core-console/sandbox_admin_test.go rename to services/web/sandbox_admin_test.go diff --git a/services/core-console/server.go b/services/web/server.go similarity index 100% rename from services/core-console/server.go rename to services/web/server.go diff --git a/services/core-console/server_test.go b/services/web/server_test.go similarity index 100% rename from services/core-console/server_test.go rename to services/web/server_test.go diff --git a/site/index.html b/site/index.html index 5c9878c9f..a8bccd5ba 100644 --- a/site/index.html +++ b/site/index.html @@ -15,8 +15,8 @@ OpenAgentCore @@ -28,7 +28,7 @@

One Core.
Many agents.

Run your native agent harnesses through one API. Keep execution, files and credentials on infrastructure you control.

Self-deployed Core + Web included

@@ -46,7 +46,7 @@

One Core.
Many agents.

print(agent.id)
Official Python client3.13.0 agents=v1
-

A read-only request. No model call. View protocol coverage

+

A read-only request. No model call. View protocol coverage

@@ -74,29 +74,29 @@

One Core.
Many agents.

02 / Get started

Install. Sign in.
Add a node.

Install Core and Web together with one command. Add nodes when you need them.

- Read the installation guide -
BEFORE YOU START

A Linux amd64 host with Docker and Python 3.9+. For nodes, an HTTPS address behind your reverse proxy; you can add it after installing.

The command downloads and verifies the latest stable release. Add --version v1.2.3 to select a version. For unreleased changes, build a distribution.

+ Read the installation guide +
BEFORE YOU START

A Linux amd64 host with Docker and Python 3.9+. For nodes, an HTTPS address behind your reverse proxy; you can add it after installing.

The command downloads and verifies the latest stable release. Add --version v1.2.3 to select a version. For unreleased changes, build a distribution.

    -
  1. Install Core + Web

    ZERO NODES

    Install the latest stable release. Core, Web and PostgreSQL start together. No model key required.

    curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash
  2. +
  3. Install Core + Web

    ZERO NODES

    Install the latest stable release. Core, Web and PostgreSQL start together. No model key required.

    curl -fsSL https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.sh | bash
  4. Sign in and issue a key

    Open the address printed by the installer. Sign in with the Core key in this private file:

    ~/.oac/core/secrets/core.key

    Set a default model on System, then create a project and issue a key on Projects and keys.

  5. Add a node

    Open Nodes, choose Add node, then Generate command. Paste the command on a Linux host with sudo; it prepares the host itself.

    Dockermicrosandbox

    Web shows when the node is online and ready. All nodes use the same provider.

-

Installation and enrollment do not call a model. Other installation options

+

Installation and enrollment do not call a model. Other installation options

03 / Go further

Build with a clear contract.

The pinned Agents API is the compatibility target. Check verified workflows and native differences before you build.

- +