diff --git a/.github/workflows/agents-executor.yml b/.github/workflows/agents-executor.yml deleted file mode 100644 index 8a6db4b26..000000000 --- a/.github/workflows/agents-executor.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: agents-executor - -on: - push: - branches: [main] - paths: - - 'packages/codex-executor/**' - - 'scripts/*agents-executor.sh' - - '.github/workflows/agents-executor.yml' - - 'Makefile' - pull_request: - paths: - - 'packages/codex-executor/**' - - 'scripts/*agents-executor.sh' - - '.github/workflows/agents-executor.yml' - - 'Makefile' - -permissions: - contents: read - -concurrency: - group: agents-executor-${{ github.ref }} - cancel-in-progress: true - -jobs: - native-build: - runs-on: ubuntu-22.04 - timeout-minutes: 30 - env: - CARGO_HOME: /home/runner/.oac/cache/executor-cargo - CARGO_TARGET_DIR: /home/runner/.oac/cache/executor-target - CARGO_BUILD_JOBS: 4 - steps: - - uses: actions/checkout@v7 - - name: Install native build prerequisites - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: agents-executor-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-executor/Cargo.lock') }} - - name: Check shared workspace helpers and independent release build - run: | - make check-agents-executor - make build-agents-executor diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 70d0851f2..cf729e58e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,10 +30,6 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 - env: - CARGO_HOME: /home/runner/.oac/cache/executor-cargo - CARGO_TARGET_DIR: /home/runner/.oac/cache/executor-target - CARGO_BUILD_JOBS: 4 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 @@ -47,13 +43,6 @@ jobs: npm install --global pnpm@10.30.3 sudo apt-get update sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: core-executor-${{ runner.os }}-${{ hashFiles('packages/codex-executor/Cargo.lock') }} - name: Install Web acceptance browser run: | pnpm install --frozen-lockfile diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml new file mode 100644 index 000000000..f947efae4 --- /dev/null +++ b/.github/workflows/native-daemon.yml @@ -0,0 +1,79 @@ +name: native-daemon + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Build the native daemon + run: go build -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + - 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 + - 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 + -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 + -run 'TestNative|TestRuntimeInit|TestRuntimePreparationRejects|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + - uses: actions/setup-node@v6 + with: + node-version: '22' + - 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 + -run 'TestWindowsPackageManager|TestWindowsRuntimeMCP' + - name: Install pinned native Harness dependencies + run: | + npm install --global pnpm@10.30.3 + pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... + pnpm --filter @parsar/claude-sdk-adapter build + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + mkdir -p "$RUNNER_TEMP/claude-runtime" + cp packages/claude-sdk-adapter/package.json "$RUNNER_TEMP/claude-runtime/" + cp -R packages/claude-sdk-adapter/dist "$RUNNER_TEMP/claude-runtime/" + npm install --prefix "$RUNNER_TEMP/claude-runtime" --omit=dev --ignore-scripts --package-lock=false + - name: Verify native Harness startup without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-package "$RUNNER_TEMP/native-tools/node_modules/@openai/codex" --claude-runtime "$RUNNER_TEMP/claude-runtime" + - uses: actions/upload-artifact@v6 + with: + name: oac-daemon-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-daemon* + if-no-files-found: error diff --git a/.github/workflows/native-installer.yml b/.github/workflows/native-installer.yml new file mode 100644 index 000000000..44d620945 --- /dev/null +++ b/.github/workflows/native-installer.yml @@ -0,0 +1,81 @@ +name: native-installer + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundle: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + 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=$GITHUB_SHA" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + node --test scripts/build-native-installer.test.mjs + - 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" + 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. + # Flattening isolated package links changes Node dependency resolution. + rm -rf "$RUNNER_TEMP/claude-runtime/node_modules" + pnpm --dir "$RUNNER_TEMP/claude-runtime" install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + - name: Build the pinned MiniMax companion on Unix + if: runner.os != 'Windows' + run: | + source_repository="$(node -p 'require("./packages/mcode-harness/source.json").repository')" + source_revision="$(node -p 'require("./packages/mcode-harness/source.json").revision')" + source_version="$(node -p 'require("./packages/mcode-harness/source.json").version')" + git init --quiet "$RUNNER_TEMP/mcode-source" + git -C "$RUNNER_TEMP/mcode-source" remote add origin "$source_repository" + git -C "$RUNNER_TEMP/mcode-source" fetch --depth 1 origin "$source_revision" + git -C "$RUNNER_TEMP/mcode-source" checkout --detach FETCH_HEAD + npm install --prefix "$RUNNER_TEMP/mcode-native" --no-audit --no-fund --include=optional --install-strategy=nested "@minimax-ai/code@$source_version" + MCODE_NATIVE_SOURCE="$RUNNER_TEMP/mcode-source" \ + MCODE_CLI_DIR="$RUNNER_TEMP/mcode-native/node_modules/@minimax-ai/code" \ + MCODE_HARNESS_BUILD_DIR="$RUNNER_TEMP/minimax-runtime" \ + bash scripts/build-mcode-harness.sh + - name: Package and exercise CLI-only installation, additions and reuse + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/build-native-installer-ci.mjs + - name: Verify native Harness protocols without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-binary "$RUNNER_TEMP/native-installer/components/codex/bin/codex${{ runner.os == 'Windows' && '.exe' || '' }}" --claude-runtime "$RUNNER_TEMP/native-installer/components/claude" + - name: Archive the native distribution + run: | + cd "$RUNNER_TEMP" + tar -C native-installer -czf "oac-native-installer-${{ runner.os }}-${{ runner.arch }}.tar.gz" . + - uses: actions/upload-artifact@v6 + with: + name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-native-installer-*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f20be5be..9697fb88a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: type: string offline: description: Also create the full offline archive - default: false + default: true type: boolean draft_release: description: Upload files to a new draft release; never publish @@ -70,15 +70,12 @@ jobs: npm install --global pnpm@10.30.3 sudo apt-get update sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal - uses: actions/cache@v6 with: path: | ~/.oac/cache/go-build ~/.oac/cache/go-mod - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: core-release-${{ runner.os }}-${{ hashFiles('go.sum', 'packages/codex-executor/Cargo.lock') }} + key: core-release-${{ runner.os }}-${{ hashFiles('go.sum') }} - name: Check release metadata and prepare pinned harnesses run: | PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py @@ -88,7 +85,7 @@ jobs: RELEASE_REVISION: ${{ steps.source.outputs.revision }} RELEASE_TAG: ${{ steps.source.outputs.release_tag }} RELEASE_REPOSITORY: ${{ github.repository }} - CORE_DISTRIBUTION_OFFLINE: ${{ inputs.offline && '1' || '0' }} + CORE_DISTRIBUTION_OFFLINE: ${{ (github.event_name == 'push' || inputs.offline) && '1' || '0' }} run: | inputs="$HOME/.oac/build/release-inputs/inputs.json" AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" diff --git a/AGENTS.md b/AGENTS.md index 9a4fbdeb1..0f10cc689 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,55 +1,14 @@ # OpenAgentCore development -OpenAgentCore is the independent execution service behind Parsar. Product business -code, its database and migrations stay in the Parsar repository. Read -[CONTRIBUTING.md](CONTRIBUTING.md) before changing code. +Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. It owns repository +boundaries, architecture, workflow, required checks and independent blind review. +Use [docs/development.md](docs/development.md) for checkout and component guidance. -## Interfaces and credentials - -Core serves three namespaces. Each has one kind of caller and its own credential; -no credential works in another namespace. - -| Namespace | Caller | Credential | Contents | -| --- | --- | --- | --- | -| `/v1` | Applications | Project API key | Exactly the pinned OpenAI Agents API route set ([upstream.json](contracts/agents-api/upstream.json): SDK 3.13.0, `agents=v1`). Fields the official types lack live only in `x_agents_core` on Agents and Sessions (`harness`, `model_provider`). Nothing about deployment belongs here. | -| `/core/v1` | Core Web's server and operator scripts | Core key; Core stores its digest | Everything about operating the deployment: Projects and keys, resource reads and deletion, Session archive, credential issuance (node enrollment tokens, executor credentials), observability, audit, sandbox deployment and nodes. | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: enrollment tokens and executor credentials issued through `/core/v1`; nodes register their own credential with an enrollment token; Core writes daemon credentials into hosted sandboxes | Machine connections only; each credential works only on its own routes. | - -- Core Web calls only `/core/v1`, through the Core clients in - `packages/agents-client` (`AdminClient`, `SandboxAdminClient`, - `CoreMetricsClient`). It never calls `/v1` or `/api/v1`, never uses - `OpenAIAgentsClient` and never forwards machine transport. -- Users sign in to Web with the Core key. Web's server forwards signed-in, - same-origin `/core/v1` requests with that key and never sends it to a browser. - The Core key cannot call `/v1`; a Project API key cannot call `/core/v1`. -- A new console need becomes a `/core/v1` route. Never add console or operations - features to `/v1`, or application features to `/core/v1`. -- List every route with its caller and credential in [docs/api/README.md](docs/api/README.md). - -## Ownership - -Backend: Core, `contracts/`, `packages/agents-client`, `services/core-console`, -the daemon, adapters, node service and installer. Frontend: `apps/web` and -`docs/web`. Agree on API shapes across the boundary; do not edit the other side's -code to make a test pass. - -## Making changes - -- Work in an isolated Git worktree on a feature branch and submit a PR. +- Keep Core, Runtime, Harness and Sandbox Provider ownership separate through the + shared protocols; see [decoupling](CONTRIBUTING.md#decoupling-principle). +- Identify every API route's caller and credential in [the API index](docs/api/README.md). - Run `make sqlc-generate` after query changes and `make openapi` after handler - annotation changes; review the generated diffs. `make openapi` writes - `openapi.yaml` (`/v1`), `core.openapi.yaml` (`/core/v1`) and - `runtime.openapi.yaml` (`/api/v1`) in `contracts/agents-api/`. Update the - matching contract there and the API index in the same change. -- Do not add product database dependencies, bypass Core execution ownership, or - reproduce execution truth in a client. + changes; review and commit the generated contracts with their source changes. - Run `make check` before reporting completion. - -## Review - -Choose independent blind review by risk: security, shared lifecycle ownership or -cross-package behavior. Give the reviewer only requirements, acceptance criteria, -boundaries, the repository path and the comparison baseline. Small verified fixes -may use self-review. Never use `codex exec` as a reviewer. - -Documentation and code comments are English; user-facing copy may be bilingual. +- Update the canonical documentation in the same branch when changing a rule, + boundary, workflow or generated contract. Keep this file a short index. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f71c09828..f2f067543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,36 @@ # Contributing to OpenAgentCore +Start with [Develop OpenAgentCore](docs/development.md) for checkout, toolchains, +component locations and focused checks. This guide owns repository-wide workflow +and architecture rules. Detailed protocols and native implementation rules have +one canonical owner, listed below; update that owner when changing its contract. + +## Documentation ownership + +| Subject | Canonical source | +| --- | --- | +| User concepts and authority | [Design principles](docs/design-principles.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 | +| Runtime messages, receipts and failure ownership | [Core–Runtime protocol](docs/runtime-protocol.md) and `internal/agentdaemon/proto` | +| Harness interfaces and onboarding | [Harness onboarding](contracts/agents-api/harness-onboarding.md) and `agent/executor.go` | +| Provider interfaces and onboarding | [Sandbox Provider guide](docs/sandbox-provider.md) and `sandbox/sandbox_provider.go` | +| Claude private bridge and Runtime artifact | [Claude SDK adapter](packages/claude-sdk-adapter/README.md) | +| Operator installation and configuration | [Installation](docs/getting-started/install.md), [configuration](docs/configuration.md), [operations](docs/getting-started/operations.md) | +| Web components, interaction and visual rules | [Web design](apps/web/DESIGN.md) and [Web architecture](docs/web/architecture.md) | +| Documentation website generation | [Docs app](apps/docs/README.md) | + +English Markdown in `docs/`, component guides and `contracts/` is authored source. +The docs app generates guide pages and API references from these sources. Never +edit generated copies to establish a different rule. Keep documentation and code +comments in English; the root project README is provided in English and Chinese, +and user-facing internationalized product copy may be bilingual. + +Historical acceptance records retain their original revisions and limits. They +are evidence, not current installation instructions or authority to restore a +retired implementation. Keep task chronology and one-off rollout reports out of +new contributor rules. + ## Repository boundary This repository is the standalone execution substrate copied from Parsar at the @@ -16,17 +47,101 @@ documented below. The source snapshot and per-file hashes provide an audit trail; future Core development need not preserve those hashes. Do not automatically sync or delete the original repository's Core. +## Decoupling principle + +Core orchestrates protocol-defined operations. Sandbox providers, Runtime +implementations, harnesses and model providers are replaceable components. +User-owned machines, E2B, Docker and other environments must expose the same +execution protocol. Resource management selects machines, allocates capacity and +creates, bootstraps, renews and reclaims Environments; Runtime initializes files, +tool configuration, packages, setup and capabilities, then executes +work and recovers within an Environment. Keep these contracts and lifetimes +separate: closing a Session executor does not release its allocation, destroy its +Environment or delete its workspace. Reclamation is an explicit resource-manager +operation coordinated with active work. + +Capability preparation uses one Runtime path for managed bundles and self-hosted +local directories. Core resolves configuration, versions +and resources; Runtime uses one parser and `installed.json` snapshot to supply +Skill paths and MCP declarations to adapters. Preparation must complete before +execution; reconnect reuses installed contents, and new Sessions freeze a new +configuration snapshot. The protocol is platform-neutral. Self-hosted daemons run natively on Linux, +Windows and macOS; managed Providers remain Linux-only. The daemon executes with +its launching user's permissions. It does not sandbox tools, files or networks, +including on Linux. Managed isolation belongs to the outer Docker/E2B Environment, +created by Core through a Provider. Harness adapters use bypass execution on all +platforms. Runtime authentication, process cleanup and state consistency remain +required; none constitutes isolation from tools running as the same user. +Installation layout may differ, but Core preparation and execution cannot branch +on operating system or Environment source. Platform support requires native CI builds and automated tests; cross-compilation +alone is insufficient. + +- Keep component boundaries explicit through shared interfaces and versioned + protocols. Register implementations behind those interfaces. Adding an + implementation must not require a new orchestration path selected by its name. +- Core owns durable Session/Turn state and scheduling. Runtime owns local + execution resources. Harness adapters translate the common execution contract + into native operations; model and sandbox provider details remain behind their + respective interfaces. +- Fix shared lifecycle, admission, cancellation, reuse and performance problems + in the common protocol or flow. Do not patch them with harness-, runtime- or + vendor-specific branches in Core. Adapters may implement native differences, + but must preserve the shared operation and event semantics. +- Express compatibility through declared capabilities and validate selected + combinations explicitly. Replaceability does not guarantee that every model, + harness and environment combination is supported. Do not silently substitute + another implementation or give a capability different meanings per vendor. +- Evolve shared contracts and their implementations together, documenting + ownership and validating the same contract across implementations. This rule + does not claim existing implementations already satisfy every target, change + the pinned public API, or authorize unrelated refactors or live Session + configuration switching. + +### Sandbox Provider contract + +The [Sandbox Provider onboarding guide](docs/sandbox-provider.md) owns integration +and operation semantics. Its canonical interface is +[`SandboxProvider`](services/agents-api/internal/sandbox/sandbox_provider.go). +Use that interface for compute lifecycle and bounded bootstrap; optional +interfaces own checkpoint and read-only observation behavior. Construction owns +vendor configuration, and common orchestration selects capabilities rather than +provider names. + +## Core–Runtime integration contract + +The [Core–Runtime protocol](docs/runtime-protocol.md) owns message order, +identities, receipts and failure ownership. Shared types and validators live only +in `internal/agentdaemon/proto`; change both peers together with an exact +wire-version check. Do not add a parallel schema or historical wire fallback. +Hosted and self-hosted peers use the same contract. The project is not public: +change current callers and implementations together instead of maintaining +historical compatibility layers, aliases or migrations. + +`make check-runtime-contract` is the focused shared-contract entry point. Its +checks also run through `check-go` and `check-agents-api` in the required full gate. +Keep failure, cancellation, disconnect and cleanup assertions in that common +suite; native adapter acceptance remains required for advertised capabilities. + ## Workflow and quality -Develop in an isolated worktree on a feature branch and submit a PR. Do not edit -or commit implementation directly on main. An empty repository bootstrap commit -is only the comparison base for the first import PR. Choose review depth by risk. Substantial changes and changes involving security, -shared lifecycle ownership or uncertain cross-package behavior need an independent -blind review after validation. Give the reviewer only requirements, acceptance -criteria, boundaries, repository path and comparison baseline. Small, verified -fixes may use self-review, including focused corrections after a blind review; -repeat independent review when a correction materially changes the design or risk. -Fix in-scope blockers before delivery. Do not use `codex exec` as a substitute reviewer. +Before development, record the requirements, acceptance criteria and scope. Make +only the changes needed for that scope; keep unrelated refactors separate. Work +in an isolated Git worktree on a feature branch and submit a PR. Do not edit or +commit implementation directly on main. + +After implementation and necessary validation, have a fresh independent subagent +review the complete diff. Give it only the requirements, acceptance criteria, +boundaries, repository path and comparison baseline, without an implementation +summary, self-assessment or earlier review findings. Explain those review criteria +to the user. Fix substantiated in-scope findings, validate, then use another fresh +reviewer. If the review/fix cycle repeats, reassess the design and scope before +adding more changes; report an unresolved blocker rather than broadening the task. +Do not use `codex exec` as a substitute reviewer. + +Search for existing formatters, parsers, validation and error mappers before adding +one. Share frontend formatting and labels in `apps/web/src/lib/`; reuse components +and tokens. Keep one error mapper per API surface. Split growing files at an +existing ownership boundary rather than adding unrelated responsibilities. The [API documentation index](docs/api/README.md) lists the three namespaces: `/v1` for applications (Project API key), `/core/v1` for Core Web's server and @@ -56,8 +171,10 @@ removed; only the historical `source:"admin_copy"` provenance read remains, fed or request bodies enter logs. The console's fixed actor label (`console`) is only an audit display label, never an authorization input. -When requirements conflict, object ownership is unclear, or a design would need -parallel compatibility paths, raise the issue with a concrete recommendation and +Apply the latest explicit user decisions when older documents conflict, and +update the affected current guidance. Historical evidence does not override those +decisions. When requirements remain unresolved, object ownership is unclear, or a +design would need parallel compatibility paths, raise the issue with a concrete recommendation and tradeoffs before implementing the disputed behavior. Continue independent work while the decision is pending. Do not silently preserve obsolete private designs. @@ -75,27 +192,9 @@ Keep uncommon resource edits and rollout details in dialogs, and avoid repeating the same information within or across pages. Core responses remain the source of truth for deployment and connection state. -For subsequent alignment and milestone closure batches, the main thread coordinates -design, shared interface agreements, file ownership, integration and merge. First -reconcile main and the boards, then list remaining mandatory milestone work, -acceptance, scope ceilings and stop conditions. Use built-in GPT-6 Astra high -subagents for two or three related tasks with disjoint file ownership, or separate -implementation from real acceptance. Do not create a design per harness or have -multiple agents edit one shared interface. Validation workers use independent -databases, ports and containers. Run targeted tests during development, then the -stable batch's required checks, real regression, generation and independent review. -Do not split an already active batch again just to adopt this workflow. Preserve -confirmed native differences and queue nonblocking findings without expanding the -milestone; stop after completing it when the user has set that boundary. - -For the continuous official-semantics alignment campaign, repeat owned-resource -API/documentation comparisons, bounded implementation batches, acceptance and -rescan until reasonably addressable discovered differences are removed. Maintain -operation-level evidence, including unverified behavior and approved native -harness/daemon differences. A merged batch does not finish the campaign. Discuss -uncertain designs before expanding mechanisms; simplify repeated patch loops and -record unresolved low-ROI cases with evidence and impact. Never defer a safety or -data-consistency blocker while claiming the affected workflow passed. +Track the selected milestone and its evidence in the active project board when +one is in use. Record unrelated findings without automatically starting them. +Do not claim completion of a broader compatibility target from one merged batch. Session creation requires initial input for `none`, and for streaming creation outside `self_hosted`. Report inline agent protocol errors first, then check these @@ -184,8 +283,7 @@ Run `make check` before completion. The standalone gate includes all daemon/shar Go tests, Core contract/client/service tests, Core Web and TypeScript client checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK -tests and packaging, MiniMax companion checks, and Rust filesystem-helper -tests/format/Clippy. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. The test database role +tests and packaging, MiniMax companion checks, and native daemon filesystem tests. It includes the Core distribution and installer gates, but no Parsar product Web or server gates. The full gate fails when the database variable is missing. The test database role needs CREATE DATABASE permission: managed-provider tests create and drop isolated `oac_*_tests` databases because provider identity is deployment-wide. Set `OAC_TEST_DATABASE_URL` to that dedicated database and `OAC_TEST_OFFICIAL_SDK_PYTHON` @@ -193,8 +291,7 @@ to the pinned SDK interpreter. `PARSAR_AGENTS_API_TEST_DATABASE_URL` is retired; `make check-database` reports its replacement when only the old name is set. Tests must not bypass the production provider-switch guard. -Use Go from `go.mod`, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 with rustfmt -and Clippy, and Linux OpenSSL development libraries. `make sqlc-generate` owns only +Use Go from `go.mod`, Node 22, pnpm 10.30.3 and Python 3.9+. `make sqlc-generate` owns only `services/agents-api/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, @@ -209,11 +306,9 @@ of product routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider acceptance. Real execution checks require real models; do not count omitted -prerequisites or mocked responses as live acceptance. `packages/codex-executor` -retains only the directory, write and workspace-export Rust helpers. Its build and -check targets remain; the separate `packages/codex-harness`, its build/check scripts -and its CI/`make check` gate are retired. Historical remote native probes are not -current validation entrypoints. +prerequisites or mocked responses as live acceptance. Workspace Files run in the +daemon's portable Go implementation and are covered by its tests. Historical +remote native probes are not current validation entrypoints. ## Core operational metrics @@ -245,9 +340,9 @@ offline backfill is allowed. ## Architecture boundaries -The following execution rules are retained from the source contributor guide. -References to the product describe the external client boundary, not components -included in this repository. +These rules govern execution ownership across components. Operation-specific +wire behavior and acceptance evidence live in the linked contracts. References +to the Parsar product describe an external client boundary. ### Product and execution service separation @@ -313,23 +408,6 @@ databases, credentials and migrations. The product uses Core exclusively; it has Parsar uses the same public contract as any other client, with no privileged endpoint or direct execution-table access. An OpenAI endpoint is a possible client target only where the requested capabilities and credentials support it. -- Maintain tasks, priorities and evidence in the Feishu board. Register issues - discovered during a task without switching work or automatically selecting them - next. Only a direct acceptance blocker justifies a minimal in-scope fix. After - each bounded task passes checks/review and merges, mark its child entry done. - Treat each selected concise Core TODO item as a delivery milestone. The long-term - Goal retains the objective, constraints and standards; detailed-board entries are - smaller tasks within that milestone. Batch related small tasks when they share a - functional outcome and safety boundary, then validate and independently review - the batch once stable. Do not manufacture one PR per internal wiring step. - Finish the selected milestone before choosing another. The main agent selects - tasks; subagents handle technical design, scoped collaboration and review, not - prioritization. Stop after the complete milestone when the user sets that boundary; - do not automatically claim the next one or mark the long-term objective complete. Product integration, UI and business Team work remain - outside Core delivery. Prioritize a sound architecture skeleton - and correct principal workflows with real API validation. Record and defer - low-frequency corner cases when risk and ROI permit; do not let minor details - delay the main work. Required checks and material correctness guarantees apply. - Parsar owns users, workspaces, business authorization, Agent/Team definitions, capabilities, product conversations, IM/sharing, approval decisions and billing. @@ -363,21 +441,6 @@ the same transaction as its Session and creation identity when this resource is implemented. Keep mutable connection/registration state out of immutable configuration; replacement ownership must fence stale observations. -The current MVP covers Codex, Claude Code and MiniMax Code through the shared -single-Agent path: Session -creation, environment preparation, native execution, files/artifacts, cancellation, -reconnection/recovery queries, and standalone deployment acceptance. Select each -bounded child from the complete board within the selected concise-TODO task; -nonblocking local improvements stay queued. -Authentication, tenant/credential isolation, state consistency and data loss remain -material acceptance requirements. Optional feature equality is not required. After -the three profiles pass merged-main validation, publish the results, limitations -and backlog. The user has since renewed continuous Core delivery: select and -finish large tasks from the concise TODO, retaining bounded child acceptance. -Additional harness implementations remain queued. The separately authorized -Subagent batch targets the six read operations across these three harnesses and -does not change the complete pinned protocol target. - The current hosted architecture is V1: Core runs independently; each Environment sandbox contains its daemon, selected native harness, local tools and workspace. Execution and Files use the same authorized workspace through the existing @@ -451,7 +514,7 @@ forwarding. The explicit daemon-executor decision supersedes the previous native executor interoperability requirement. The superseded execution route is removed; retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. -The private daemon wire protocol is 0.5.0. Initial, prepared and active input use +The private daemon wire protocol is 0.8.0. Initial, prepared and active input use the same ordered MessageInput contract, replacing scalar prompts and attachments. User-message boundaries and text/image order remain intact through Core and the Runtime wire; adapters own native conversion and receipt aggregation. Text-only @@ -461,10 +524,10 @@ unqualified harnesses (Claude SDK, MiniMax Code) reject them at admission rather their input rewritten. Codex has a flat native input list and uses blank-line separators between messages; this does not preserve independent native user-message boundaries. No old wire fallback is maintained. -Deploy Core and daemon together; the existing major/minor WebSocket check rejects -older major/minor peers before dispatch rather than ignoring removed fields. -The independently packaged Claude bridge uses protocol 2 for ordered input; -readiness rejects packages reporting the old string-input protocol. +Deploy Core and daemon together; the WebSocket check requires the exact wire +version, including patch, and rejects mismatches before dispatch. +The independently packaged Claude bridge uses protocol 3 for a prepared Executor +and separately identified Turns; readiness rejects other protocol versions. Image-bearing messages require a qualified profile/placement before persistence and image support from the selected Runtime before native delivery. These checks apply to that operation only; ordinary text retains offline queueing. Initial, @@ -510,9 +573,11 @@ or load encrypted file bodies. Record original creation intent before resolution Template parsing, persistence and resolution must not select a harness or Provider, or depend on native tool names and private harness paths. The shared initializer uses the packaged Runtime contract for trusted commands, workspace/staging paths, -confidential input and completion receipts. Each Provider supplies that same -Runtime and carries initialization commands through RunCommand; each adapter owns -native tool configuration. A new harness or Provider must not require template +confidential input and completion receipts. Each Provider bootstraps the same +daemon; all initialization uses its authenticated `runtime_prepare` protocol. +The common runner depends on Environment and Session identity and a Runtime peer, +not a Provider, deployment type or operating system. Each adapter owns native tool +configuration. A new harness or Provider must not require template business-logic changes. Reuse qualified shared helpers even when their executable names have historical engine prefixes; renaming is not a boundary fix. Select real regressions by the changed shared, Provider and adapter boundaries, rather than @@ -547,36 +612,41 @@ transaction stores a safe reason with the failed Environment and records `environment.failed`, `error` and one `agent.session.failed`; Session reads derive `failed`, that reason and the failure time from the same record, and live streams end after the failed event. The initializer reports only the integer exit status of a -failed sandboxed step. Core composes the reason from a fixed step label and that status, +failed initialization command. Core composes the reason from a fixed step label and that status, never from command, package-manager or file output; unknown effects, timeouts and receipts without a status keep a generic reason. New input then gets the observed 409 `conflict_error`; expiry and pending-input settlement keep their behavior. Completed environments never reinstall initial files on reconnect or native recovery. -Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted -initializers may run with Runtime authority. User setup and package install hooks -run in the common packaged sandbox, without daemon credentials or native history. -Files, resolved Skills and inline Plugins precede system, npm/Python packages and ordered setup commands. Initialization has -provisioning network access; requested network restrictions apply to native tools -after setup. Confidential env and setup snapshots are encrypted independently of -ordinary metadata. Adapters apply tool env only after isolation, never to the -credential-bearing daemon/native harness launcher. -Reuse the packaged atomic file writer and anchored parent creation across all profiles. - -System packages use one Runtime-owned tool root, separate from trusted daemon and -harness executables. Build its immutable seed from the base image before adding -Runtime/harness code or secrets; include the matching package database and base -tool symlink targets. The shared installer extracts independent inodes and runs -apt/dpkg inside an unprivileged namespace. Package scripts cannot access Runtime -credentials, native history or outer processes. Later setup and native tools enter -the installed root read-only, retaining the authorized workspace and adapter-owned -scratch. `/workspace` and `/environment/workspace` refer to the same authorized -workspace inside that root, preserving native working directories. Native adapters -own entry and existing process cancellation; Core never -selects an engine or Provider for package initialization. No live filesystem -snapshot, second lifecycle owner or package-manager framework is introduced. -Core preserves the system-package requirement in the common execution binding; -a missing installation receipt fails preparation instead of falling back to base -tools. This requirement does not add execution prerequisites to Files reads. +The typed Runtime preparation protocol carries bounded confidential input. The +daemon owns the common Go initialization implementation; Core supplies no +executable or host-platform field. Initial files and tool configuration precede +Skill/Plugin bundles, npm/Python packages and ordered setup, followed by capability +directory capture. Commands run directly with the starting account's permissions +and host network. Outer Environments own managed isolation; unsupported network +restrictions must reject rather than silently run unrestricted. Confidential env +and setup snapshots are encrypted independently of ordinary metadata. Adapters +apply explicit tool variables without automatically inheriting daemon credentials; +this does not prevent same-user tools from reading local Runtime state. +Reuse runtimefs atomic replacement and anchored logical-workspace paths for initial +files on every platform. Public Files creation keeps its own non-replacement rule. + +System dependencies must be preinstalled in the managed image/template or by the +self-hosted user. `packages.system` rejects explicitly, including a supplied null +or empty list. Runtime does not run apt, sudo, an unprivileged system-root installer +or any other automatic system-package operation. No daemon privilege increase or +managed-only exception is permitted. Missing dependencies fail the consuming +operation. npm and Python use local prefix/target directories; setup requires Bash +and Windows requires Git Bash, without a substitute shell. +Package responses retain the official required `system: []` field as empty +response metadata; it is not stored as an initialization option. + +Initialization and package directories default under `OAC_RUNTIME_HOME` and may be +selected with `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and +`OAC_RUNTIME_PACKAGE_DIRECTORY`. Packaged Linux images select their existing +`/environment` layout through those settings. `OAC_RUNTIME_TOOL_ENV_FILE` selects +explicit configuration. These are resource paths, never Environment-source or OS +switches in Core. Runtime process ownership waits for exit and I/O settlement; +confirmed failures retain only a bounded exit status, never command output. Skills and their immutable versions are Core-owned tenant resources, independent of Sessions and native Skill installations. Serialize version allocation and pointer @@ -608,14 +678,15 @@ installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime -owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but -not modify this tree. Public Plugin ZIPs preserve their complete package +owns `skills/` below its configured capability root; the manifest validates +the installed snapshot before reuse. This is state +consistency, not protection from the launching user. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace Plugin and capability-directory lists through the same hosted resolver. After ordered setup, the existing initializer snapshots declared workspace-contained -capability directories into protected storage and writes one installed manifest. +capability directories into Runtime storage and writes one installed manifest. This is an initialization artifact, not a second lifecycle owner or database ledger. Directory bytes are observed after setup; they are not frozen at Session creation. The common daemon resolves the manifest only for executable preparation and passes @@ -634,40 +705,21 @@ fails explicitly, without silent partial activation or a generic plugin framewor Environment-origin MCP declarations use the shared Plugin parser and frozen installed packages. The installation manifest retains selected MCP package roots; -Runtime re-parses those protected packages without another configuration copy, +Runtime re-parses those installed packages without another configuration copy, credential cache or lifecycle ledger. Native adapters must explicitly qualify and project supported declarations before public admission. Parent-directory Skill discovery does not activate nested Plugin MCP configuration. -The packaged stdio entry enters the existing initialization sandbox before parsing -or executing a server. It mounts only the fixed static daemon helper, which resolves -the selected installed declaration, reads explicitly selected initialized user -variables, applies package-relative cwd and replaces itself with the server. Native -MCP stdin/stdout pass directly to the sandbox; no protocol forwarding loop, -Provider command or second Plugin parser is introduced. The existing Python stdio -entry remains a single-threaded launcher and monitors the native parent process -through Linux pidfds. Native Codex recycles spawning threads, so binding bwrap's -parent-death signal directly to those threads kills live MCP connections. The -launcher gives bwrap a stable parent, retains its parent-death/PID namespace cleanup, -and kills and reaps only that child when the native process exits. A pre-exec -parent-death signal and expected-parent PID check close the reproduced fork-to-exec -orphan window; bind libc before fork and keep this entry single-threaded. Missing -pidfd support fails closed. This fixes the OS process lifetime boundary without adding a -Core execution owner, retry or reconnect loop. -Model/daemon launch variables are never credential sources. Hosted stdio requires -enabled network; native HTTP requires its own qualified network and redirect behavior. -Claude composes the existing MCP identity/observation profile with its workspace -profile. Verify the complete native inventory before admitting tool identities; -MCP allowlist patterns never grant local Bash or file authority. Only declared -random bearer references enter native query env, with native Bash denial retained. -With system packages, Claude uses its packaged shell-prefix dispatcher. Only the -exact fixed Runtime stdio invocation bypasses the tool root; it immediately execs -the existing isolated launcher. All other full command strings pass unchanged to -the tool root. The native Bash sandbox and cwd receipt remain in their original -order. Do not move wrapping into Bash input hooks or add a directory-state owner. -Keep this native distinction in the adapter; Core and the common launcher do not -select an engine. Per-server env cannot disable the native global prefix because -Claude selects the executable before merging that env. +The common daemon stdio entry resolves the installed declaration and launches its +server under the same user permissions as the Harness. Unix replaces the helper +process; Windows forwards native stdio within the owned process tree. Explicit +initialized values override the selected declaration's variables. There is no +Python sandbox launcher, mount policy, shell prefix or separate credential sandbox. +Process groups and Windows Jobs own cancellation and descendant cleanup only. +Claude composes MCP identity and observation with the same workspace profile; +MiniMax keeps its existing supported transport qualification. A public capability +advertisement must still match the installed engine's actual supported transport. + Environment-origin literal HTTP headers remain rejected for the pinned Claude client because its interpolation and cross-origin forwarding change their meaning. MiniMax accepts environment stdio only. Its adapter reads the existing Session-private @@ -677,18 +729,20 @@ never fabricate a delayed start event, guess normalized identities or add a regi of our own. Its unqualified HTTP transport remains rejected. These mechanisms alone do not establish public MCP support or full compatibility. -Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/system/npm/Python are +Name, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified batches. Resource reads need only tenant authorization, not a live Runtime. See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). -SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. -Use maintained provider SDKs and thin adapters. Hosted deployments select one deployment-wide Provider: E2B cloud, or Docker/microsandbox on administrator-owned nodes. -Provider initialization creates the sandbox and starts its daemon/harness; -RunCommand is for initialization only. Daily execution and Files use Runtime and -native or bounded local capabilities. Docker's lack of a native renewable lease +SandboxProvider owns Create, GetInfo, Renew and Kill, with placement and daemon +bootstrap at the resource-management boundary. Use maintained provider SDKs and +thin adapters. Hosted deployments select one deployment-wide Provider: E2B cloud, +or Docker/microsandbox on administrator-owned nodes. Providers do not execute Core +initialization commands. Initialization, daily execution and Files use the same +daemon through typed Runtime operations and native or bounded local capabilities. +Docker's lack of a native renewable lease does not remove service-owned hosted expiry and cleanup requirements. The official `openai_hosted` discriminator means hosting by this independent Core @@ -1051,11 +1105,12 @@ 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 base operations -(Create, GetInfo, Renew, Kill, RunCommand) and the optional CheckpointProvider +`services/agents-api/internal/sandbox` contract owns the four base operations +(Create, GetInfo, Renew, Kill) and the optional CheckpointProvider capability. Core orchestration must not import an adapter or SDK. Exact compute identity, generation construction, inspection, full snapshot capture, restore, -thaw, command execution and owned artifact cleanup use that common capability. +thaw and owned artifact cleanup use that common capability. Initialization and +execution use the authenticated Runtime peer. Provider-specific names and snapshot identities are opaque to Core. Self-hosted compute and providers without checkpoint support keep their existing behavior. @@ -1089,8 +1144,9 @@ root or subagent Turn, pending input/file operation or initialization remains, and real activity has been idle for the configured interval. For node-managed allocations, record the first root or child terminal transition in the same transaction using Core's database clock and the existing compute activity field. -Native completion timestamps remain unchanged in public history but cannot drive -idle admission across hosts; repeated terminal projections never reset that timer. +For every Environment source, positive native completion timestamps remain +unchanged in public history, even when host clock skew places them before Core's +Turn creation time. They cannot drive idle admission across hosts; repeated terminal projections never reset that timer. Read activity together with the database observation time. Candidate filtering and the Session-locked phase recheck compare elapsed database time with the configured idle duration; callers must not supply a Core-wall-clock cutoff. Anchor the initial @@ -1125,7 +1181,7 @@ a chain of old writable disks across suspension cycles. No Kubernetes, distribut scheduler or snapshot replication belongs in this V1 profile. Queued work and live Environment file access request wake. History and published -artifact reads do not. Planned suspension uses private daemon wire 0.5.0 with an +artifact reads do not. Planned suspension uses private daemon wire 0.8.0 with an Environment and suspension token; a PID/start-time fenced local control signal wakes the parked daemon, which reauthenticates before admitting new work. A transient disconnect before confirmation retries the same armed suspension with @@ -1195,13 +1251,12 @@ continue bounded scans for late resources without issuing another Create. This conservative internal lifecycle does not define user-managed enrollment or prove complete upstream expiry/error semantics. -Qualify the actual Docker/native sandbox before default cutover: real model -execution, file access, owned cancellation, restart with retained native history -and files, and rejection when required history is missing. Generated code and file -tools must not read daemon/model credentials, foreign Session history or another -tenant's workspace. Same-container placement, matching UID, mode bits, directory -bindings and capability flags do not prove isolation. Retain failed probes and -unverified limits; private functionality is not public hosted acceptance. +Qualify the actual Runtime before cutover: execution, file access, owned +cancellation, restart with retained history and files, and clear failure when +required history is missing. Daemon tools run with the launching account's full +permissions. Managed isolation is provided by the outer Environment. A user who +installs on a host does not receive a sandbox or protection from their own tools. +Keep failed probes and unverified platform combinations explicit. Before migration, archive existing edits and validation evidence. Reuse verified authorization, resource/lifecycle ownership and safe filesystem primitives as @@ -1210,38 +1265,16 @@ mechanisms. Remove superseded unused code, configuration, tests, scripts and task-owned temporary resources as each replacement is accepted. Preserve necessary regressions, still-used official capabilities, product data and others' work. -The opt-in Codex deployment selector `OAC_RUNTIME_CODEX_PERMISSION_PROFILE` chooses a -native named profile at harness startup and on both new/resumed threads, omitting -the legacy sandbox override. It is operator configuration, never a prompt option, -and rejects remote, none and temporary read preparations. Native managed -requirements own allowed profiles and deny-read enforcement. Keep the selector -unset for existing deployments. Managed native shells disable shell snapshots, -whose private files are inaccessible to tool execution; retain normal native shell -startup without granting tools access to harness state. -The [co-location qualification inputs](services/agents-api/deploy/codex/README.md) -record the pinned native/Docker prerequisites and limits; this switch alone does -not admit hosted Environments or authorize a workspace. - -A managed Runtime's network policy is immutable deployment input, transferred -through the provider-neutral bootstrap and checked against execution preparation. -The shared policy includes enabled, disabled and an exact-host restricted allowlist. -Core preserves public spelling/order/duplicates and only permits Template overrides -that narrow authority. Adapters translate a normalized copy into native settings; -Core and Docker never select native profile names. Every hosted execution peer -must support `local_environment_network_policy` and receive the complete bound -policy; missing policy never falls back to enabled or an older peer path. Read-only -workspace access does not require execution network policy. A declaration alone -does not qualify an image or admit public hosted creation. - -Codex restricted networking uses its native managed network requirements and proxy. -Its adapter preserves the image's filesystem, approval and hook requirements and -adds the frozen exact-host ceiling in Session-private state. The existing RPC -client owns a bubblewrap child that mounts those requirements read-only and runs -the stock native app-server in a PID namespace; teardown retains the same owner. -Preparation regenerates the non-secret requirements from the frozen policy. Keep -the file with Session state so cleanup cannot race a running child's mount. -Claude and MiniMax translate the same policy into their native sandbox allowlists. -These translations do not add a Core network service or model/tool loop. +All Harness adapters use bypass execution. Do not restore named Codex permission +profiles, bubblewrap wrappers, native Claude sandbox settings or MiniMax +SandboxManager branches. There is one execution path for every Environment origin. +Resource paths are ordinary operator configuration, not a permission boundary. + +The daemon does not enforce disabled or restricted network policies. Such a +combination must be rejected unless its outer Environment implementation provides +and qualifies the requested behavior. Do not advertise daemon-level network +isolation or silently run a restricted request with unrestricted semantics. +The normal self-hosted combination uses the host's existing network access. A dedicated local Runtime uses one Environment-scoped device credential and an immutable binding to that Environment's Session. It is excluded from general @@ -1256,8 +1289,7 @@ 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 existing filesystem helper directly, with no model credentials or -temporary harness. These private capabilities alone do not authorize public requests or establish +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 @@ -1375,21 +1407,18 @@ capability advertisement alone does not qualify an operator's deployment. Exporter component checks do not establish public Artifact compatibility. Local inline file delivery uses the same authenticated daemon connection and exact -Environment/Session binding. The optional startup-owned `OAC_RUNTIME_WRITE_HELPER` -and `OAC_RUNTIME_STAGING` enable only the bounded installer primitive; they do -not grant public feature admission. Require a canonical executable outside the -Environment parent, canonical sibling workspace/staging directories on one mount, -and verified native tool denial of staging and its ancestors. Native credentials -and history remain outside that parent. Mode bits and path checks alone do not -qualify this layout. The read-only deployment needs neither writer setting. +Environment/Session binding. All platforms use the daemon's Go implementation +for bounded file reads, directory listing, file creation and output export. +Files operations require no external helper executable or staging directory. +The Files API keeps workspace-relative paths and no-overwrite creation semantics; +it does not restrict native Harness tools' host permissions. Transfer a complete bounded body in acknowledged 64 KiB frames before invoking -the existing installer, verify the declared digest, and run no model for upload. +the native file writer, verify the declared digest, and run no model for upload. Keep the private 50 MiB transfer bound distinct from the official 5 MiB decoded inline bound, which the API checks before any Runtime work. Files.create uses the -installer's explicit create mode: parents are created without following links and -an existing path is never replaced. Initial Session files and Skills keep the -installer's replace mode; do not change one caller's mode for another. +native writer's no-overwrite operation. Initial Session files retain their separate +atomic replacement behavior; do not change one caller's semantics for another. The dedicated Runtime excludes execution while receiving or applying a write; malformed, incomplete or expired transfers cannot reach the installer. Exact commit/rejection receipts release the mutation owner. Missing or ambiguous @@ -1413,17 +1442,19 @@ failure when the authorized workspace cannot be reached; it never grants public admission merely because an adapter advertises a capability. Use distinct authorization for callers, devices and environment connections. A -co-located harness must not expose broader application credentials or other tenants' -secrets to generated code. Directory bindings and process identities do not provide -filesystem isolation. Preserve or demonstrably restore native history across +managed outer Environment must exclude broader application credentials and other +tenants' secrets. The daemon does not hide its own state from same-user tools. +Directory bindings and process identities do not provide filesystem isolation. +Preserve or demonstrably restore native history across 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](contracts/agents-api/environments.md) and the [two-engine placement prerequisites](contracts/agents-api/workspace-placement.md). -For co-location, qualify both deployment isolation and native tool restrictions. -Bash sandbox settings alone do not establish file-tool or whole-harness isolation. -Never enable an environment profile before those boundaries are verified. +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. +Never enable an execution combination whose required outer behavior is unverified. The internal Store creates one Environment with an environment-bearing Session in its creation transaction. The Session upsert selects the retry winner; retries @@ -1438,8 +1469,9 @@ the Session row/cursor in the same transaction, without borrowing subsequent activity or Turn state. Initial state is `pending`; authenticated connection observations follow the lifecycle rules below. Public creation supports `self_hosted` on the three enabled native profiles when -the daemon gateway is configured. V1 requires `/workspace` and empty/default -capability directories. Supported non-deferred functions keep their engine-specific +the daemon gateway is configured. The workspace is a clean absolute host path matching the bound Runtime root; +`/workspace` denotes that root through the existing logical mapping. Optional +capability directories are clean absolute host-local selections. Supported non-deferred functions keep their engine-specific validation and native callback bridge. Enrollment binds the dedicated Runtime; Session output uses the owned Environment association. Public Files reuse the exact local workspace; populated self-hosted installation metadata remains unsupported. @@ -1578,8 +1610,9 @@ rotation/revocation and digest-only storage. They grant connection authority, ne 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 V1 workspace is `/workspace`; nonempty -self-hosted capability directories remain unsupported. No `runtime_allocation` is +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. @@ -1654,103 +1687,130 @@ reconciles old connection observations before admitting new ones. A connection o heartbeat does not prove native readiness or process quiescence. Failed or stale observations cannot establish a current connection. -Preparation resolves the immutable local binding and holds the existing native -resource without creating model work. Start transfers that resource once; failed -start and abandoned preparation retain the established cleanup rules. Strict resume -uses the bound native history and never falls back to a new Session. - -Every executable preparation must implement `PreparedCancellation`; read-only -preparations may implement only `Prepared`. The Router rejects and closes an -executable preparation before `ready` when that contract is missing. Codex fences -future Start under the transfer lock; unused resources use preparation teardown, -while transferred resources use the existing Session cancellation path inside the -adapter. `Close` remains inert after transfer. -`CancellationOutcome` exposes observed content, Usage and verified native identity; -an unstarted resource has no measured Usage or observed resume identity. This is -best-effort cancellation and an observed snapshot, not immutable final output, -notification drain, caller-deadline compliance or remote process quiescence. -From Start admission onward, the exact `PreparedCancellation` object is the sole -release target: a late Session never receives fallback `Cancel`, and the Router -never falls back to preparation `Close` or owner-context cancellation. Successful -`Cancel` means local cleanup is complete and no more output writes can occur. A -failed or timed-out call returns without waiting for output, retains execution -ownership, and permits only a later explicit serialized retry on that same object. -Before publication, failed cleanup also retains the preparation slot. Successful -publication permanently transfers resource tracking to the Run; subsequent release -does not restore preparation ownership or make its old handle cancel that Run. -Forwarded permission and user-choice observations from that cancelled handoff do -not register actionable interactions. Codex prepared cancellation also waits for -the transferred Session's local cleanup, which can finish after output closes. -Codex cancellation has one continuing native owner: caller deadlines bound only -their wait, leaving child observation and cleanup alive for an explicit retry. -Only observed child terminal facts can settle the child; actual observation -failures remain failures. Native process-exit confirmation remains retryable. -One prepared-output consumer starts before `Prepared.Start`, so native output beyond -the 64-frame channel capacity cannot deadlock Start. It retains the first terminal -frame, drains later output, and forwards accepted frames before the observed cancellation -outcome receipt. Missing capability, failed cancellation or failed forwarding cannot -produce an applied receipt. An unused resource may supply an empty observed outcome; -the Router never fabricates one. - -The returned Session remains private until the `started` status send succeeds. The -handoff has one permanent release claim, one current native attempt and one -success-only settlement. Function results, permission decisions, user-choice -decisions and steering admitted before the claim hold the same operation barrier -through native submission, replay bookkeeping and receipt delivery. Natural -completion waits for Start publication before cancellation; an abort wakes that -same attempt and may fence a concurrent Start. The initial started-status send is -bounded by the preparation deadline, which is rechecked at publication commit. -Delayed expiry callbacks cannot cancel a successfully published handoff. The successful attempt waits for Start and the sole output -consumer, then forwards the retained terminal frame and removes the Run. Internal -paths join the current attempt; only an explicit cancel, Release, expiry, device -shutdown or later Router Shutdown may retry a failed attempt. Workspace reads require -a published, open Session but keep their independent bounded lifecycle. - -Receipt settlement waits at most ten seconds, with a separate five-second send -budget and the existing gateway settlement deadline. A timeout does not free the -resource or stop tracking late cleanup. Shutdown cancels receipt waits while owned -Start/cleanup work remains tracked. This ordering covers cancellation received while -Start is pending; ordinary post-transfer cancellation, complete native output and -remote process exit retain their separate limitations. - -The private daemon preparation controls reuse execution configuration but reject -input, RunID, Conversation, attachments and product authoring. The local profile -requires an exact Environment binding, stable state key, strict resume and completion -release. Its separate capability is registered through an execution-only factory -and preserved through the product registry wrapper and heartbeat mapping. Native -details remain inside the adapter; this private profile does not narrow upstream. - -Preparation request IDs correlate only control responses. The daemon returns a -fresh opaque handle before slow work; Start supplies that handle and the actual -RunID/prompt. Handles belong to one daemon connection. Gateway preparation -subscriptions do not register Runs. Per-handle revisions order asynchronous status -snapshots; reject responses describe control errors without inventing run events. -At most four native preparations may be preparing, ready, starting or closing. -Start admission expires five minutes after acceptance; retries do not extend it. -Failed cleanup retains the native resource and its capacity until Close succeeds; -terminal handles with retained resources cannot be pruned or started again. -At most 64 request records are retained; retired request IDs may allocate a fresh -handle, while old handles cannot consume replacements. This is not durable -exactly-once preparation or cross-connection recovery. - -Preparation and Start execute outside the receive loop and router lock, with -tracked lifetime work. Start reserves the real RunID and fixed cancellation target -before native work; cancellation in that phase uses the adapter contract across -the transfer. A late result cannot resurrect released ownership. Shutdown claims -or retries every prepared release under the lock before closing its cancellation -signal. A failed native attempt returns Shutdown without waiting on an output -consumer that may still be blocked; a later Shutdown retries the same target. -Successful publication stops the preparation deadline and uses the same prepared -output consumer and completion release; -later preparation Release cannot cancel that Run. Released/expired status makes -the handle unusable; asynchronous native cleanup still counts toward capacity and -does not promise immediate OS quiescence. Release retries retained cleanup. -Concurrent Shutdown calls join one tracked attempt within their caller deadlines; -a later call retries failed preparation cleanup and reports any remaining error. -A caller timeout does not discard ownership or repeat in-flight cleanup. These -records remain connection-local, not a persistent remote retirement fence. -The public idle-text path uses this -admission/start wiring; complete Environment lifecycle remains required work. +A Session owns one reusable Executor in its connected Runtime. A Turn owns one +input execution, its output stream and its cancellation. `agent.ExecutorFactory` +prepares the fixed configuration; `Executor.StartTurn` creates a new `agent.Turn` +without replacing healthy native resources. Normal completion settles only the +Turn. `Executor.Close` releases native resources on idle expiry, environment +shutdown or confirmed invalidation. Core does not keep a second Executor cache. +The same lifecycle applies after managed or user-managed environments connect, +and to the qualified no-environment profiles. Resource management owns machine +selection, allocation and Environment creation/reclamation. Closing an Executor +does not release the Environment allocation or delete its workspace. Environment +reclamation explicitly coordinates with Runtime execution. Connection, installed +capability snapshot, Session Executor and Turn retain separate lifetimes. + +The Runtime binds its Executor record to Session, Environment, connection and +immutable execution configuration. Resume identity and prior-Turn recovery flags +are continuity assertions, not configuration changes. A supplied native identity +must match the retained owner; exact-history recovery never starts a new root +when existing history is required. A configuration conflict is an error, not a +hot switch. Lost connections retire their owners and handles. Old timers, output +and cancellation cannot affect replacements. + +Each Turn receives a fresh wrapper, output channel and receipt state. Optional +steering, functions, permissions and user-choice interfaces belong to that fixed +Turn. Native callbacks capture the originating Turn before asynchronous work; +late events cannot be assigned to whichever Turn happens to be active. Native +processes, query/transport connections, fixed capability configuration and native +Session identity belong to the Executor. Do not reset completed `sync.Once` +values or repurpose an old Turn object. + +`StartTurn` returning nil guarantees that no native input was submitted and the +output channel was not retained. The Runtime then closes that channel. Once input +may have been submitted, return a non-nil Turn even with an error: the Turn owns +exactly-once output closure and remains tracked until settlement. Unknown input +is never replayed. A definite `executor_unavailable` Start rejection permits one +common recovery attempt only after the previous Executor has been closed and no +input was submitted. Recheck the same physical peer and current authorization. + +`Turn.Cancel` targets only that Turn and does not close a healthy Executor. +`AwaitSettlement` applies after both natural completion and cancellation. Success +means output can no longer be written and the Turn's native events, input, +functions, interactions and child work have settled. Native completion or +cancellation confirmation is independent of resource retirement: closing a +transport cannot supply missing native terminal or operation receipts. +`Reusable=true` additionally +confirms that the native owner can accept the next Turn. `Reusable=false` requires +a reason and subsequent confirmed Executor close. An error means settlement is +unconfirmed; it cannot free ownership or capacity. Caller deadlines stop waiting, +not tracked cleanup. Retry the same cleanup target serially. Failed cleanup +blocks replacement and retains its resource slot. Executor Close confirms resource +retirement independently of the Turn outcome: an immutable Turn error must not +prevent closing the native transport and releasing resources once their work and +output have stopped. + +One output consumer starts before native Start, drains the bounded 64-frame +channel, and retains the terminal observation until Start publication, Turn +settlement and admitted operation receipts finish. Natural Done never calls +Cancel. Input, function and interaction admission close before settlement, and +operations already admitted hold their barrier through native receipts and +outbound acknowledgement. Send cancellation to the fixed Turn before waiting +for that barrier: a written input may need native interruption to produce its +receipt. Join native settlement, any required confirmed Executor close, output +drain and all admitted operations before an applied acknowledgement or reuse. +A failed Close may report failure while retaining the same Run and outstanding +operations for retry. Closing a caller wait cannot manufacture an applied input receipt. Only then forward Done or an applied cancellation +receipt. Commit native continuity and release the old Run admission before +publishing Done, since the receiver may immediately start another Turn. A late +terminal-send failure belongs to the old Run; it cannot invalidate a successor +that already owns the Executor. Connection shutdown owns transport-loss cleanup. +Preserve the ten-second settlement wait and separate five-second receipt +send budget; timeout is not proof of quiescence. The observed cancellation outcome +retains native identity, Usage and output without fabricating missing evidence. +Codex native Turn interruption can leave background terminals alive. Its adapter +uses the exact thread-owned terminal list and confirmed per-terminal termination +before settling cancellation, and repeats this cleanup during Executor close. +The bulk clean acknowledgement does not prove termination. Failed cleanup keeps +the native owner available for a later close attempt. + +Private preparation controls reserve a per-Turn admission, not a new Executor. +They carry an explicit Session identity and immutable configuration without model +input or Run identity. A fresh request returns a connection-local admission handle +and the owning Executor ID. Start supplies both identities and its actual Run ID +and ordered MessageInput. Per-admission revisions order status observations; +rejections describe control errors without inventing Run events. A reused healthy +Executor returns ready without native preparation. An admission release abandons +that admission; it does not close the Session's healthy idle Executor or cancel a +later Turn. Cancellation uses the exact Run identity. + +Preparations and Start execute outside the receive loop and Router lock. Admission +expires after five minutes; retries do not extend that deadline. Bound active +preparation and execution separately from idle retained resources, and count +closing or uncertain resources until cleanup succeeds. A definite +`execution_prepare` rejection with `preparation_capacity` leaves an unclaimed +queued Turn for the existing Worker scheduler to retry, including capacity held +by cleanup. Other errors and uncertain input delivery do not authorize replay. +At most 64 admission records are retained; old handles never consume replacement +admissions. Idle expiry +is a Runtime resource policy, not Core active-Turn concurrency. Shutdown tracks and +closes active and idle Executors, retains failed close targets, and allows a later +serialized retry. Ordinary disconnection closes the failed transport and keeps +the exact Router until shutdown succeeds. A wait timeout or failed cleanup cannot +authorize reconnect; process shutdown also keeps waiting rather than silently +discarding owned native resources. These records are connection-local, not durable +input replay. + +Read-only workspace preparations remain separate bounded filesystem operations; +they cannot start model work. Workspace operations retain exact binding and +settlement rules across Turn boundaries and Executor closure. + +Successful input commits send a coalesced hint to the existing Worker scheduler. +The scheduler keeps lease, capacity, cursor fairness and per-Session ownership +checks; a hint does not admit work itself. If capacity is occupied, preserve one +rescan for completion without turning failed preparation into a busy retry loop. +HTTP readiness waits and active input delivery subscribe before reading relevant +state and wake after committed promotion or input. They recheck storage after each +hint. Polling remains the fallback for external writers, expiry and lost hints; +notifications contain no execution authority and no durable input data. + +Core readiness, Start acknowledgement and input-to-first-text logs use a single +process monotonic clock. Start acknowledgement confirms adapter ownership, not +model input consumption. +Runtime logs identify Executor creation, reuse, idle and close independently of +Turn completion. Do not call these durations model-only latency or subtract clocks +from different machines. Native process creation and same-owner successive Turns, +real provider results and same-condition timings must substantiate reuse claims. The Dispatcher prepares pending Environment input only on its exact enrolled or managed device. Preserve the same physical peer and preparation handle through @@ -1898,13 +1958,9 @@ and output umask remain unchanged, including when invoked under umask 077. The installer packages Core and the Web console together, with independent `--core-only` and `--web-only` modes. `site/` is the public static landing, separate from `apps/web`; it must not create an onboarding prerequisite, -call a model, or claim complete protocol compatibility. The root README starts with -the three newcomer steps (install, sign in and issue a key, add a node), each linking -to `docs/getting-started/`: installation, nodes, self-hosted executors, operations and -the application developer quickstart. `docs/configuration.md` is the single settings -reference; building distributions and running Core alone live in `docs/maintainers.md`. -Use Web's page and action names in docs, and `OPENAI_BASE_URL` and `OPENAI_API_KEY` for -application examples. +call a model, or claim complete protocol compatibility. Use Web's page and action names in user docs, and `OPENAI_BASE_URL` and +`OPENAI_API_KEY` for application examples. The [documentation ownership](#documentation-ownership) +map defines the authored sources. A distribution carries a fixed list of docs (`BUNDLED_DOCS` in `scripts/core-distribution-manifest.py`). The build keeps relative links between @@ -1947,8 +2003,11 @@ private temporary files, verify before atomic promotion, and reuse only verified cache entries or exact image identities. Core's default image must not acquire execution-only payloads. Python zipapps bundle the shared resolver with each remote bootstrap; the console publishes only fixed non-secret files and declared -artifact names. Release automation builds artifacts and may create an unpublished -draft, but cannot claim real execution qualification or public availability. +artifact names. Candidate build automation creates artifacts and may create an +unpublished draft; a successful build is not real execution qualification. A +separate existing-host batch controller may publish that draft automatically only +after directly supervised real qualification and verified batch landing. Repository +visibility remains internal and independent of Release publication. Manual builds use the legal `build-` release tag; tag-triggered builds use the actual `v*` tag. The manifest download base and draft tag must match, while artifact filenames and source provenance retain the full source SHA. @@ -1961,6 +2020,70 @@ image containing a private test CA or model credential as a release input. Repository visibility is independent of publication. Do not add repository credentials to installed node/Runtime configuration to bypass download access. +`scripts/promote-qualified-release.py` requires an explicit full candidate source +SHA, binds it to the archive manifests and matching `build-` tag, and uses +existing local gh authentication and SSH. It uploads/downloads the complete +matching thin/offline/Runtime asset set and verifies archive members and asset +hashes. The separate qualification package is supplied by the maintainer with an +explicit reviewed manifest SHA256. Its complete file inventory, ordered Python +commands, bounded stage timeouts and private path/resource configuration are +verified before any Release mutation and again by the remote supervisor. Candidate +assets cannot select or replace this execution package. Keep host-specific +acceptance scripts, usernames and credential paths outside this public repository; +never put credential values in either manifest. + +The reviewed adapter receives a fresh canonical UUID and exact inventory over the +authenticated command channel. It directly supervises fresh-install, +current-lifecycle, managed-native-smoke, diagnostics-observations-smoke and +node-runtime-smoke in that order. This batch uses one fresh container installation, +one completed managed Session, read-only diagnostics for that Session, and one +current Runtime Session on one new node. It does not rerun the full multi-host, +generation or GC matrix. Every child must exit successfully and +return only its own passed check, the current controller identity and its observed +owned resources. Resources and the previous result flow between live children; +a supplied pass file, skipped check or old report cannot release the candidate. +Verify package and candidate bytes again after each stage. The small shared +`qualification_control.py` is pinned to the reviewed tooling commit and private +package. A live SSH stdin channel carries the request then heartbeats; EOF, timeout, +SIGTERM or SIGHUP stops later work. Each local stage or remote worker has one +foreground process group and a waiting owner outside that group. The owner cleans +the group on success, nonzero exit, timeout and cancellation, including foreground +descendants orphaned by an inner timeout or SIGKILL. Nested foreground commands +inherit the group; only explicitly recorded background resources may detach. +Those retained background resources are outside foreground cleanup. Private nested +workers use the same channel. +Already-issued writes may have unknown outcomes: retain intents/resources and do +not replay or claim rollback. Control tests exercise +short-lived fixture children only and never establish live qualification. + +The caller supplies the independently reviewed promotion-tooling commit. Its +changes from the candidate may only affect the exact promotion files enumerated +in the controller, including CONTRIBUTING, docs/maintainers and the current-batch +node-generation protocol wording correction; the Makefile +exception permits only registration of the controller and control-channel tests. Main must contain the +candidate source and have the reviewed tooling commit's tree. This permits normal +merge commit identity changes and release-only documentation updates without +rebuilding or relabeling the original candidate. The candidate's bundled docs and +source archive retain source 48 (CONTRIBUTING and the node-generation protocol +are present through the source archive, not as direct bundled docs); new release instructions live in the tooling +commit. Product changes or a different main tree block promotion of the old +candidate. Never infer batch membership from all open PRs or automatically merge +them in the publication command. + +Use one controller invocation for the batch. After successful qualification it +waits in the same process, within the explicit merge-wait budget, for the exact +reviewed batch tree to reach main. An ancestor main waits; conflicting main changes +fail immediately. Cancellation or expiration retains evidence and cannot turn a +saved result into resume authority. It verifies unchanged draft identity, +target, tag and downloaded bytes immediately before publication. After the final +download it rechecks main/tree/tag and the same draft ID, then updates that verified +Release ID directly rather than resolving the tag again. It checks the +published bytes afterward. Conflicting assets are never overwritten. An interrupted +run is reconciled before another invocation; stored qualification output is evidence, +not a resumable permission to publish. Preserve its isolated local/remote evidence +and installation resources. No runner, background service, new GitHub secret or +repository-visibility change is required by this finite batch path. + Executor credentials are issued by the operator with the Core key, through Web or a Core-key script, under `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` @@ -1969,17 +2092,17 @@ Environment of that Project whose Session exists; anything else is 404. The Project's principal is the credential's execution principal, its scope stays daemon enrollment and connection for that one Environment, and issue, rotate and revoke each record an administrator audit entry in the write's transaction without -the secret. Project API keys cannot issue them. Self-hosted installation reuses -Docker Runtime isolation, owns no sandbox node or Core allocation, and retains -user-owned native history after uncertain launches. Report started, connected and real execution success separately. -Web's install command carries no secret: the installer reads the credential at a -hidden terminal prompt and verifies against the console's -`self_hosted_installer_sha256`. On a permanent rejection the self-hosted daemon -parks (one message, no requests, exit 0 on SIGTERM) rather than exiting into -Docker's `unless-stopped` restart loop. Rerunning the installer with the same key -rotated replaces it in the same stopped container through the launcher's -`replace-credential`, which checks the container's labels, name, volumes and -private directories; never relaunch or adopt history to recover a credential. +the secret. Project API keys cannot issue them. Native self-hosted installation runs the daemon on Linux, macOS or Windows with +its starting account's permissions. It owns no sandbox node or Core allocation, +adds no isolation and retains user-owned native history after uncertain launches. +Report started, connected and real execution success separately. +Native installation uses `oac-daemon install` and `start` with an explicit +credential-file path and the same `OAC_RUNTIME_HOME` for lifecycle commands. It is +current-version only and does not adopt an older container installation. Rotation +replaces the configured credential file for the same key and restarts the daemon; +never create a replacement Session history to recover a credential. The console's +older container-installer command remains a distinct packaged workflow, not the +native installation interface. Report connection and actual execution separately. Core-key executor credential lists expose a required connection observation with never_enrolled, connected or disconnected status, immutable bound key identity, enrollment time and last authenticated heartbeat time. Read credential metadata @@ -1996,8 +2119,8 @@ using only its restricted executor credential. The read checks the exact live Environment/key binding and current authenticated connection; it never enrolls, allocates, wakes a sandbox or grants project resource access. It is an `/api/v1` machine route that reaches Core directly, never through the console. Bounded -polling and reruns retain the original container and history; timeout is a -diagnostic failure, not permission to relaunch. The installation public URL +polling retains the original Runtime identity and history; timeout is a +diagnostic failure, not permission to replay initialization or replace history. The installation public URL (`public_url` in the installation's `config.json`, seeded by `--public-url`, and `OAC_PUBLIC_URL` for Core) is the one origin for applications, nodes, sandbox guests and self-hosted executors, and also the console @@ -2086,13 +2209,11 @@ uninstall with the previous release before adding it again. The machine configuration route rejects `X-Parsar-Node-ID` with `400 invalid_request`; only `X-OAC-Node-ID` identifies a retained node credential. -Self-hosted installations use `oac-selfhost` and `~/.oac/self-hosted/`. -The installer refuses an existing `~/.parsar/self-hosted/` for the -same Environment, even with a custom new install directory. Old executors keep -running with their own image and history; operators explicitly stop and remove -them before replacing them. New installation never adopts their native history. -Credential replacement for a new installer-owned executor retains its existing -container and ownership checks. +User-managed hosts use the native daemon installer on Linux, macOS and Windows. +It does not select a supplier or create compute resources. The retired Docker +self-hosted installer/launcher and console payload are not retained as fallback. +Existing environments, credentials, workspaces and native history are never +automatically deleted or adopted by a new installation. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive @@ -2116,9 +2237,11 @@ TLS reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime daemons, with their own credentials) directly to Core and everything else, including `/core/v1`, to Web. Operator scripts call `/core/v1` on Core's loopback port. -Nodes and Core come from one distribution; older nodes using the removed -`/core/v1/sandbox` node paths cannot connect and are replaced through the drained -upgrade and re-enrollment workflow. +Nodes and Core come from one distribution. Older nodes using the removed +`/core/v1/sandbox` paths are unsupported; preserve their installation and data and +use a separate fresh installation. There is no drained in-place upgrade or +historical re-enrollment procedure. Current-version Runtime generation rollout +retains its separate resource lifecycle. The Web manager offers no manual Core key entry outside sign-in, and Web refuses to start without its Core key file. It holds no Project API key and never calls @@ -2218,9 +2341,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti - `internal/agentdaemon/gateway` is the shared daemon connection implementation. Its persistence interfaces use `internal/agentdaemon/device`, never product Store - types. Product adapters live in `server/internal/agentdaemon`. Keep protocol - frames in `internal/agentdaemon/proto` until the contracts directory migration. - Store aliases preserve existing callers during this transition. + types. Shared protocol frames and validators live in `internal/agentdaemon/proto`. - Session deletion uses a durable `sessions.deleted_at` marker. Public deletion accepts only a durably idle or failed Session without required actions: no queued, in-progress or waiting root Turn and no pending input reservation, the @@ -2259,8 +2380,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti the column while deleted records exist, preventing public resurrection. - `services/agents-api` owns its SQL schema, sqlc queries and embedded goose migrations. `OAC_DATABASE_URL` is required; never fall back to the product - database URL. Its first persistence slice stores tenant-scoped Sessions with a - stable engine and idempotent creation. It does not switch production execution. + database URL. It stores tenant-scoped Sessions with a stable engine and durable + creation retry identity. - Reusable Agents have their own tenant-scoped `agents` records, independent of Session snapshots, engine bindings and product Agent definitions. The Store persists caller-validated non-secret configuration and metadata without applying @@ -2611,7 +2732,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti immutable configuration snapshot. Inline and historical creation identities include the resolved configuration; new saved references use the separate caller intent. Session metadata updates replace only metadata under the authenticated tenant: - omission is a read, null/empty clears, and a nonempty object replaces all pairs. + a supplied metadata field is required, null/empty clears, and a nonempty object + replaces all pairs. Keep execution state, timestamps and the original creation request hash unchanged; creation retries return the current resource without restoring its old metadata. Public schema validation belongs to the API; the Store validates JSON structure. @@ -2623,7 +2745,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Preserve the existing tenant-owned cursor and creation-time/ID ordering rules. 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 both services. Run +- `make sqlc-generate` and the drift gate cover this service. Run `make check-agents-api` 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 @@ -2705,31 +2827,28 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti require native steering receipts. Commit terminal outcome and native Session ID together under the admission lock; unapplied messages prevent successful completion. Resolve credentials separately from the immutable non-secret snapshot. -- Internal execution requires the advertised `durable_turns` engine capability, - strict resume, and optional cancellation receipts - and `release_on_completion` support. Reject unadvertised peers before claiming; - failed strict resumes must not fall back to a new native thread. Release the native writer before forwarding - completion, so the next Turn can resume its durable native ID. For - release_on_completion Runs, close new steering admission and finish all - existing steering receipt sends before releasing the executor and forwarding - Done. Cached input identities and conflicts remain readable while completing; +- Internal execution requires advertised durable Turns, strict resume, preparation, + applied input receipts and the qualified operation capabilities. Reject + unadvertised peers before claiming; failed strict resume cannot start unrelated + history. A completed Turn closes steering admission, settles existing native + operations and receipt sends, and closes its output before the retained Executor + can accept another Turn. Normal completion does not cancel the Executor. + Cached input identities and conflicts remain readable while completing; queue/write success is not consumption. The receipt worker stays busy through its send, and router shutdown cancels its native and transport waits. - `durable_input_receipts` is required before execution binding/claiming. The - per-input `durable_receipt` opt-in requires release-on-completion and a phased - adapter. Its ten-second transport timer stops only after a complete native write; - a separate `written` acknowledgement stops the API's thirty-second delivery timer. - Neither that phase nor legacy `in_flight` advances the input cursor. Await final - native acceptance/consumption under the Run lifetime without automatic redelivery. - Receipt sends retain a separate five-second shutdown-aware context, and Done - retains a fifteen-second final settlement bound. Once cancellation is sent, its - receipt owns the terminal outcome even if an input becomes unknown first. - Calls without the opt-in retain their existing response deadlines. Existing product - requests retain their default idle-process policy. Native history still requires - the device's persisted engine files; IDs alone cannot restore deleted history. - Cancellation receipts carry the stopped engine's continuity snapshot when no - Done is emitted. Preserve separately reported usage on failure; do not add the - same counters again when Done also includes them. + The per-input `durable_receipt` opt-in requires a phased adapter. Its ten-second + transport timer stops only after a complete native write; a separate `written` + acknowledgement stops the API's thirty-second delivery timer. Neither phase + advances the input cursor. Await final native acceptance/consumption under the + Turn lifetime without automatic redelivery. Receipt sends retain a separate + five-second shutdown-aware context, and Done retains a fifteen-second final + settlement bound. Once cancellation is sent, its receipt owns the terminal + outcome even if an input becomes unknown first. Calls without the opt-in retain + their existing response deadlines. Native history still requires the device's + persisted engine files; IDs alone cannot restore deleted history. Cancellation + receipts carry the stopped Turn's confirmed continuity snapshot when no Done is + emitted. Preserve separately reported usage on failure; do not add the same + counters again when Done also includes them. Usage frames carry cumulative snapshots for the current execution, not deltas. Adapters publish observed snapshots promptly through the same ordered stream; waiting for Done unnecessarily loses known measurements if the Runtime stops. @@ -3024,16 +3143,15 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Item's Session position under the Session lock, preserving observation order for equal timestamps. Allocate a separate zero-based `output_index` per Turn; inputs do not consume output indexes. Updates and retries retain both values. - Existing indexed history keeps its pre-upgrade deterministic order; missing - original ordering cannot be reconstructed. Cursors are scoped to the authenticated Session. + Persisted indexed history keeps its deterministic order; missing original + ordering cannot be reconstructed. Cursors are scoped to the authenticated Session. Terminal Turns expose unfinished Items as `incomplete`, preserving completed message/tool states independently of the Turn outcome. - Public history reads use the persisted index; the private pre-Items journal - backfill is retired. Migration 15 rejects unprepared historical Turns before - removing the obsolete indexing marker. Prepare them with release `906069e` - before upgrading, following `services/agents-api/README.md`. The migration - preserves indexed Item payloads, positions, output indexes and source journals; - never mark unprepared history indexed by hand or replay engine execution. + backfill is retired. Migration 15 and its historical validation remain evidence, + not a supported upgrade procedure. An old installation is unsupported and must + be retained separately from a fresh installation. Never mark unprepared history + indexed by hand or replay engine execution to convert it. - Project only the declared public Item variants; native adapter metadata is not a response schema. Preserve structured tool JSON without float conversion. Completion text replaces accumulated deltas. Item merging must not mutate the @@ -3062,7 +3180,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti A creation retry returns the existing Session without re-admitting initial work, including after terminal or later Turns. Omitted/null input is permitted only for non-streaming hosted creation and self-hosted creation. - Creation streaming uses the shared live path above; non-text messages remain a gap. + Creation streaming uses the shared live path above. Image support requires the + qualification in [the message-input contract](contracts/agents-api/message-input.md). - Enabling daemon transport with `OAC_PUBLIC_URL` also starts a bounded execution worker. Select only connected, capable devices owned by the authenticated tenant; bind once and preserve native continuity. Metadata cannot select a device. Offline work stays @@ -3113,14 +3232,14 @@ Owned output pipes remain readable after the leader exits. Consumers must drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters -must close their query, await their native child and drain observations before -publishing completion. Process groups are lifecycle supervision, not OS isolation +must settle each Turn and drain its observations before publishing completion. +Executor close additionally closes the query and awaits the native child. Process groups are lifecycle supervision, not OS isolation or containment of descendants that deliberately leave the group. ### Harness qualification and onboarding Codex, Claude and future harnesses have equal architectural status. The common -Runtime wire protocol and Factory/Session/Prepared interfaces own lifecycle, +Runtime wire protocol and Executor/Turn interfaces own lifecycle, input receipts, cancellation, recovery and resource access; each native adapter retains its implementation and model/tool loop. A new engine supplies an adapter, a qualified profile in `services/agents-api/internal/engine`, registration and @@ -3139,7 +3258,7 @@ remain separate. Runtime advertisements alone never enable public operations. Shared dispatch checks capability combinations, not a whitelist of engine names. Harness onboarding does not require feature equality. Verify common lifecycle obligations and use the same public assertions for each declared operation, -retaining native isolation tests where appropriate. Optional native differences +qualifying outer isolation where the deployment requires it. Optional native differences remain independently prioritized capability/protocol work, not onboarding blockers. Keep the complete pinned public protocol target and accepted functionality intact. Never equate accepted parameters with applied native behavior. @@ -3168,41 +3287,51 @@ 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, cancellation -settles the process and output, and continuation requires the exact owned native -history. Do not infer history IDs or qualify hosted execution from this text +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 [deployment and acceptance](services/agents-api/deploy/mcode/README.md). +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 isolates native -workspace tools behind its standard MCP client. The process and native Session +lockfile through the existing companion packaging path, and connects native +workspace tools through its standard MCP client. The process and native Session share one private control directory; public workspace files cannot configure that process or become privileged project instructions. A trusted adapter-owned bridge -runs the original six tool implementations in the upstream Linux sandbox, with no -unsandboxed fallback. Keep native history bound to the control directory and Files/ -Artifacts bound to the public workspace. Core and shared file helpers remain engine +runs the original six tool implementations with the launching user's ordinary +permissions. It adds no inner sandbox on any platform. Keep native history bound +to the control directory and Files/Artifacts bound to the public 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 applies the protected workspace policy to every child, -not only the root's configured profile. Only the Session's authorized internal +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 or bypass the native profile's read/write restrictions. -Initialization must acknowledge that protected tool policy before input as well. -Subagent reads use the Session-private protected native database. Multi-agent +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 [workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) 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 -Docker qualification (including `native.test.mjs` under both network policies) -are required when the companion changes. +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. -Claude hosted functions compose the existing SDK function bridge with the native -workspace sandbox. Only declared function tools and the verified native tool +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 @@ -3212,38 +3341,23 @@ credential, history, subagent or network authority. ### Claude dedicated Docker Runtime Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use -`scripts/build-claude-runtime.sh` with the existing shared workspace helper build. -See [deployment and engine onboarding](services/agents-api/deploy/claude/README.md). -The helper executables retain their historical Codex names; their local directory, -write and export operations are shared and do not launch an engine. - -`OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed` requires the shared dedicated local binding, -canonical workspace and its same-inode `/workspace` mount, and explicit immutable -network policy. Native history, home and scratch live separately under -`OAC_RUNTIME_HOME/runtime/claude-sdk`; daemon authentication stays under -`OAC_RUNTIME_HOME/daemon`. The trusted image and protected staging directory -remain outside writable workspace roots. No product state or native user profile -is imported. The separate unbound `environment:none` profile keeps its behavior. - -The Docker operator option `nested_sandbox` is false by default. The qualified -Claude image requires it: Docker supplies an init process and permits nested procfs -mounting by removing its outer `/proc` masks/read-only submounts. `/sys/firmware` -and powercap remain masked; the root and sysfs mounts remain read-only, capabilities -remain dropped, and the existing seccomp/no-new-privileges policy remains enabled. -Do not enable privileged mode, weaken the native sandbox, mount host process state, -or apply host-global policy changes. This is an image deployment prerequisite, -not a public API option or an engine-name branch in the Provider. - -The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract. -Registration combines that contract with the verified operator binding. Core uses -an explicit accepted engine profile independently of advertisements. This profile -supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation -and same-history continuation. The separately advertised `workspace_functions` -combination supports declared public functions with text results. Service-origin -HTTP MCP remains unqualified here; its existing `none` support is retained. -Environment Plugin MCP follows the separately qualified transport path above. -Native Bash network access uses the harness's HTTP proxy; no alternate networking -or tool loop is implemented by Core. +`scripts/build-claude-runtime.sh`. Native self-hosted installations use the same +bundle and daemon protocol. See [engine setup](services/agents-api/deploy/claude/README.md). +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 @@ -3296,7 +3410,7 @@ features, not new Core lifecycle or public protocol variants. The current qualified path is Claude SDK, `environment:none` or Core-managed Docker `openai_hosted`, medium verbosity, single Agent, with optional ordinary function tools and text results. The workspace uses its existing preparation and -native sandbox with only the SDK's configured `StructuredOutput` tool added to +bypass execution profile with the SDK's configured `StructuredOutput` tool added to inventory and permission checks. Frozen schemas reach preparation before the input handoff; Start cannot replace them. Skills, Plugins, capability directories, HTTP MCP, Subagent/tool-discovery combinations and schemas without an explicit @@ -3319,424 +3433,16 @@ qualification limits are recorded in [the coverage note](contracts/agents-api/st ### Claude SDK adapter foundation -`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK -and native message translation. The Go `claudesdk.NewFactory` uses the shared -owned process runner and emits the existing daemon delta/error/Done frames. -The SDK owns the model loop. Its narrow stdio protocol carries a start request, -text deltas, function calls/results/receipts, active text input/receipts, usage -snapshots and one terminal result/error; native translation stays inside the adapter. -With `observe_messages`, it also emits the existing neutral `output_message` -start/completion snapshots and tags deltas with the native Messages API message -ID, not the SDK event UUID. Text blocks in one native message share that identity. -The SDK's per-block assistant snapshots replace draft block text; only native -`message_stop` completes the message, without replaying its text as another delta. -Thinking/tool-only messages produce no text Items; interrupted messages retain -their streamed partial text. No phase is inferred from the final result. -SDK/native child release and output draining precede daemon completion. - -`claudesdk.Config.Workspace` is a private, trusted operator binding for one -qualified placement. It enables native Bash/Read/Edit and declared host functions -in the existing SDK loop. The entire factory must already run inside an outer mount/process boundary -that excludes application, daemon and other-tenant credentials and host policy. -The factory does not create that boundary. The dedicated Docker profile below -selects this binding at startup; cwd and request options cannot select its policy. -The workspace, managed history, runtime home, scratch and protected secret roots -must be pre-existing canonical, separate directories. Runtime code and dependency -search paths must remain outside those roots and be read-only in the placement. -The complete packaged runtime directory, including `node_modules`, must not -overlap any bound root. Its bridge uses the packaged `dist/main.js` layout. -Node, the bridge entrypoint and its readiness companion must use canonical file -paths. Dependency aliases outside mutable roots are resolved before use in PATH; -aliases within mutable roots are rejected even if their current target is safe. -The operator owns allocation, exclusive use and retention; a binding is not -per-Session authorization, a tenant boundary or an idle Files owner. - -For this profile, `Config.Env` replaces inheritance for both readiness and -execution, selecting only supported provider/proxy variables. The adapter fixes -HOME/history/scratch, native enforcement flags and tool inventory; the bridge -request contains variable names, never credential values. Native Bash uses the -strict sandbox with no fallback or weaker isolation. Separate native file-tool -permissions and a session tool hook restrict Read/Edit to the bound workspace -and deny protected roots; background/unsandboxed Bash requests are rejected. -The deployment must retain these controls, including the SDK-owned hook. -External MCP and remote-environment combinations remain rejected in this private -profile until separately qualified. The existing `none` profile retains its -behavior. Packaged `workspace_tools` establishes bridge support only, not host -isolation or a public capability. The dedicated Runtime integration composes -public preparation, shared placement quotas, command Items and Files ownership. -`TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a -qualified placement, including effects, cancellation and same-history continuation. - -The private workspace bridge also accepts `prepare` without a prompt. It freezes -validated configuration and resume identity, checks required history, and retains -one native process through the pinned SDK's `startup`/`query` API. Preparation -requires initialization and acknowledgement of the required hooks while the input -iterator remains empty. Its `prepared` receipt permits one later `start` containing -only the initial prompt; configuration replacement, premature or duplicate start -is rejected. Native Session identity and actual tool inventory are still checked -at execution initialization before `input_ready`. Existing direct execution uses -the same observation and completion path. - -Unused EOF, owner signals, invalid control input and native exit release owned -resources before a terminal event. The private `workspace_prepare` runtime feature -identifies this bridge contract only. It does not register daemon preparation, -enable public admission, or project public Environment readiness. Preparation may -write native runtime metadata outside the workspace and perform startup traffic; -it does not prove provider authentication, complete sandbox health or tenant -placement authorization. - -`claudesdk.NewPreparationFactory` privately binds that workspace bridge to -`agent.Prepared`; the existing workspace factory uses the same Prepare/Start path. -Preparation receives configuration and required resume identity without a RunID or -prompt, checks the installed `workspace_prepare` feature, and owns the process until -one successful Start transfers it. The selected configuration and environment are -fixed before returning; a later Start supplies only its actual RunID, prompt and -output channel. Early preparation failure returns without an executing Session or -fabricated completion. The non-workspace direct factory keeps its existing path. - -The preparation owner context spans the eventual Session. Start's context bounds -that operation only, and Close is inert after successful transfer. Abandoned or -failed preparation, owner cancellation and native exit release owned work. The -same output consumer and cancellation settlement follow the resource across Start; -an unstarted preparation has no measured Usage or observed native Session identity. -A cancellation deadline cannot establish cleanup completion while cleanup remains -pending. This adapter ownership seam does not register a daemon capability or -supply per-Session placement authorization, public admission or an idle Files owner. - -The optional private `agent.WorkspaceReader` on this preparation and transferred -Session requires the packaged `workspace_read` feature. It sends bounded relative -paths to that same SDK Query's native `readFile` control. Only the adapter combines -the path with the frozen workspace root; callers cannot replace the placement. -The pinned native read handler awaits file-handle close before its successful -base64 response. The adapter validates bytes and truncation, bounds each result -to 1 MiB and each request to 8 KiB, and admits one read at a time. Its continuous -bridge output consumer retains read receipts during preparation and across Start. -Caller cancellation detaches observation without cancelling the Run or discarding -an admitted waiter; its original deadline still applies. Owner closure stops -admission. Native null, malformed receipts, timeout and interrupted delivery remain -uncertain and stop the owner; local reap is not a successful read settlement. -The SDK's nullable result catches all native/control errors, so it cannot distinguish -missing files from denial or transport failure. This does not provide a public -Files endpoint, snapshot consistency, placement registration or idle owner policy. -The qualified live workspace fixture also checks binary, empty and bounded reads -before input and during real execution, plus effects before cancellation and reads -on fresh-process history continuation. - -The optional private `agent.WorkspaceDirectoryLister` requires the Linux-only -`workspace_directory` bridge feature on the same preparation or transferred Session. -The pinned SDK has no directory-enumeration control; its fuzzy file suggestions are -not an inventory. This narrow adapter operation therefore reads metadata inside the -already qualified co-located mount/process boundary. It pins the configured workspace -root and opens each relative directory component with `O_DIRECTORY | O_NOFOLLOW`, -using `/proc/self/fd` paths anchored to held descriptors. It rejects directory symlink -traversal; `lstat` reports a symlink entry itself without following its target. -The fixed workspace policy requires canonical, disjoint protected roots and excludes -dynamic permission replacement and remote-workspace fallback. It does not implement -an additional permission engine or authorize an unqualified placement. - -Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit -truncation, literal names, kinds, and sizes only for regular files. They do not promise -ordering, snapshots, recursion, or public pagination. Missing and permission errors -are returned only from distinguishable filesystem outcomes; unknown results stop the -owner. Each operation closes its directory and intermediate descriptors before a -successful receipt; the root descriptor remains owned until bridge release. -Caller cancellation, Start transfer and owner shutdown retain the existing workspace -read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated -Runtime integration supplies public placement and ownership. - -With `ObserveToolObservations`, private workspace execution requires the packaged -`workspace_command_observations` feature and emits the existing neutral command -snapshots. Match root, current-query native Bash call/result identities after input; -ignore historical replay, synthetic and child work. Preserve exact command text and -the native per-call textual result, including native rendering or truncation. This -is final native output, not incremental stdout/stderr or reconstructed interleaving. -Native error results are failed; unambiguous structured interruption is incomplete. -Missing results close as incomplete after the observation drain; query cancellation -does not overwrite an already observed native failure. Do not infer an -exit code from rendered text or supply cwd/duration without qualified native fields. -Preparation alone emits no command. Cold continuation must not reissue historical -observations. This private translation does not enable public workspace admission, -Read/Edit Items or Files ownership. - -The private adapter also 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 Claude MCP admission reuses the shared resolver, immutable Session snapshots -and neutral Item/event projection. -The API checks the supported profile before persistence, during device selection -and again before claiming execution; a missing runtime capability leaves work queued. - -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. This does not permit filtering actual model/tool output to hide a leak. -The bounded adapter profile currently 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 retain -their existing inventory checks without a new pre-input connection requirement. -A Runtime must advertise the concrete required-initialization capability; there -is no fallback to an older execution path. - -Public Claude static-bearer HTTPS MCP reuses the shared -Vault attachment, frozen selection and scoped decryption path. Selection and final -preclaim require the existing bearer capability; shared authentication dispatch -uses capability/placement checks rather than a Codex-name restriction. Missing keys -or failed lookup/decryption never fall back to anonymous execution; an attached -Vault with no matching credential may remain anonymous. These are execution limits, -not saved-Agent schema restrictions or changes to the official protocol. - -Root assistant tool calls and live root user results produce the existing neutral -MCP observations. Correlate actual Session/call identities; exclude replay, -synthetic and subagent work and keep host function receipts separate. Preserve -the exact native `tool_use_result` when one result is unambiguous, otherwise the -per-call result content. Native errors remain observed native errors. The SDK can -replace annotated MCP content with rendered structuredContent and flatten MCP -errors; these observations do not claim original MCP envelope fidelity or hosted -output parity. Do not reconstruct lost fields or infer output from model prose. -Unfinished observed calls become incomplete on shutdown, without claiming that -remote tool effects were cancelled. Rich content, native truncation and asynchronous -MCP task results remain unverified. - -Daemon `connect` optionally registers this factory as `claude_sdk` when the -operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. -`OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves -Node once and checks that exact configuration before pairing; the SDK's bounded -runtime check is independent of legacy CLI version probes. A ready SDK alone is -sufficient to start the daemon. No configuration means no SDK probe or descriptor; -failed readiness reports an unavailable descriptor with a rejecting factory. -Runtime checks establish local readiness, not provider authentication. - -SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently -of the replaceable runtime bundle. Both the entrypoint and managed state root must -be absolute. Background re-execution inherits operator configuration; it does not -persist provider credentials in pairing profiles. Product `claude_code` remains -unchanged. Product registration explicitly opts existing engines into -`WorkspaceAuthoring`; the authoring registry wraps only that opt-in. SDK registration -bypasses product capability-download, skill-upload and workspace-authoring wrappers. -It does not accept caller-supplied environment variables or business write authority. - -The SDK descriptor advertises the validated daemon subset, including durable -Turns/input receipts, text observations, function tools, raw usage and restrictive -execution controls. It does not advertise permissions, product authoring, legacy -raw tool Items, general web-search control or text-verbosity levels. Router admission -for `environment:none` uses the available engine capability, not an engine name. -The independent API selects new Session engines through `OAC_DEFAULT_HARNESS` -(`codex` by default, `claude_sdk` or `mcode`); existing Sessions keep their stored engine. -This remains the deployment default; the optional Core harness extension selects -an enabled engine for one saved or inline Agent configuration. API admission, -device selection and the final preclaim check share the execution service's narrow -engine policy without importing native adapters. Selected engines require the common -durable execution capabilities. Codex retains its general search/verbosity checks; -Claude uses its restrictive profile without claiming those general capabilities. -Idle and initial-input Session creation qualify the resolved configuration before -persistence; saved Agent resources remain independent of engine restrictions. -Claude additionally requires medium verbosity and explicit object-root function -schemas. Function-result batches normalize through the existing shared parser. Claude accepts -text results and, on `none` and Core-managed Docker `openai_hosted`, successful -ordered inline PNG/JPEG results. Unqualified placements, failed image results and -invalid/remote references reject before any batch write, preserving pending calls -and retry identity. Public qualification receives the full neutral result so -success-dependent limitations remain in the profile. Image-bearing delivery alone -requires Runtime function-result image support; text results and function -declarations do not acquire that requirement. These are implementation limits, not changes to the upstream contract. -Do not bypass them by dropping fields, changing model identity or fabricating usage. -Operators may configure the daemon provider environment or the deployment default -model provider for `claude_sdk` (HTTPS `base_url` and a write-only key), which Core -freezes in the Session's encrypted snapshot and delivers as the adapter-owned -`claude_provider`; it never enters public Session configuration. The adapter exclusively selects the -provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged. -The `none` public profile accepts only -text, explicit model/system instructions, managed state, exact native resume and -declared functions with ordered text or successful inline PNG/JPEG results, and the HTTP MCP subset -described above. It rejects unsupported request -options and disables built-in tools and undeclared MCP discovery. -`DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about -the single-Agent restrictive profile. Omission does not enable built-in tools. -Explicit Subagent observation enables only its qualified native delegation tools, -with admission before start and verified child identity before workspace authority. -Public function/MCP combinations remain unqualified with Subagents. Single-Agent -new and resumed queries use the SDK's empty built-in tool set, explicit function MCP -configuration and allowlist, strict MCP configuration and empty user/project/local -setting sources. Without HTTP MCP declarations, native initialization and real -provider request inventories must contain only the declared host functions. Managed operator policy may further -restrict execution; it must not widen the profile. This limits model tool access, -not native state files or filesystem access by an explicitly supplied host function; -it is not sandbox/file isolation. The private factory accepts typed execution -controls only for disabled search and medium text verbosity. Search remains excluded -by the native tool inventory; medium retains the SDK's default text generation, -without adding instructions or changing caller input. The pinned SDK has no native -verbosity-level option: low/high and enabled search remain explicit implementation -gaps. Missing/invalid fields in a supplied control block fail before native setup; -omitting the block keeps the same restrictive profile. Public engine admission is -qualified separately by the API policy described above. -Use the SDK's history lookup before explicit resume; never fall back to a new -Session. Native files remain device-affine under a caller-selected managed -runtime directory. The launch configuration supplies trusted provider environment; -request options cannot supply environment variables or business write authority. -Omitted, null and empty `system_prompt` map to empty SDK instructions only at this -adapter boundary; null model values and unsupported options remain rejected. - -The internal SDK function-server helper uses the maintained MCP server's public -request handlers and standard Tool/CallToolResult types. It snapshots definitions -and forwards JSON Schema without a JSON Schema-to-Zod conversion; supplied tools -are always loaded. Native call identity comes from the pinned harness's -`claudecode/toolUseId` MCP metadata, independently of request IDs, names or arrival -order. Missing identities and undeclared tools fail before invoking the host. -Return content/error fields unchanged over MCP and forward its per-request abort -signal. The private Go factory connects declared functions through this helper and -reuses the daemon function-call/result interface and opt-in neutral observations. -The native function-server registry must contain exactly those functions. SDK allowlisting admits -only these host callbacks; the host still owns result decisions and any business -permission checks. It grants no runtime-token business authority. - -Function results remain pending after stdin/MCP delivery. A matching live, root -native user tool_result confirms application only when its Session/call identity, -error flag and ordered content match the submission. Text matches exactly; each -submitted image position must remain a valid native base64 image. Native resizing -or re-encoding may change image bytes. This acknowledges incorporation into native -history, not byte/pixel fidelity or completed provider consumption. Public Items -retain the original caller content; real image-dependent model responses separately -qualify usability. Ignore replayed, synthetic and -subagent messages. Native error text joins the submitted text parts with newlines; -neutral observations retain their original order and separate failure status. -Missing/mismatched receipts fail the execution; do not replay unknown delivery. -Result submission waits at most ten seconds for a receipt and cancels uncertain -execution on timeout. Invalid or unsupported image results fail before consuming -a pending call. Function state belongs to one live Run and ends with it; the -existing router owns receipt retry/conflict handling. This does not establish -crash recovery or exactly-once effects. Public schemas outside MCP's object-root -contract, failed image results and remote image references remain admission/execution gaps. - -Each SDK result supplies one native usage snapshot, including reported failures. -`Usage.Raw.claude_sdk_result` holds the latest; queries with multiple native results -also retain all snapshots in order under `claude_sdk_results`. Main-loop `usage` -is per native turn, while query-pipeline `modelUsage` and estimated `total_cost_usd` -are cumulative within the query. Retain subtype/error provenance and earlier -snapshots even when a later failure reports zero counters. Reuse the latest full -snapshot set in Usage and Done; never sum cumulative measurements. Each factory -invocation owns one SDK query, including cold resume, so no prior query counters -are carried forward. Missing native results do not imply zero consumption. SDK estimates stay -in raw evidence, outside the billed cost field; do not select an arbitrary model -or invent missing public token breakdowns. The API does not parse native counters. -Precise public usage projection, unreported costs and crash/partial accounting -remain gaps; the native snapshot alone is not complete protocol Usage compatibility. - -Active text uses the SDK's `AsyncIterable` input, with a fresh -native UUID mapped to each daemon input ID. A native query may fold text into its -current native turn or queue another; one daemon Run can therefore contain several -native turns. Never promise Codex's same-native-turn semantics. Writes, queued -notifications and user-message echoes do not confirm consumption. Only matching -root assistant/partial/result `user_message_uuids` (or the singular fallback) -confirm applied input. Typed mid-turn folds may appear only on the native result. -Preserve that receipt even when the result reports failure. Check pending functions -after the query drains: the SDK may dispatch later-turn callbacks before the -earlier result handler finishes. -Keep the iterator open until every submitted input has a consuming result, even -when an earlier result reports an empty native queue. Close admission before -releasing final receipt waiters; drain and release the SDK/native processes before -one daemon Done. Cancellation resolves unconfirmed pending receipts as unknown and ends the -owned execution. Successful private SDK Cancel waits for owned-process exit and -stdout/stderr drain, then exposes the same settled CancellationOutcome as Done. -It retains native identity verified at input readiness, partial text and observed -Usage even on cancellation/failure; requested resume identity alone is not evidence. -A caller deadline before settlement reports failure/unknown, while cleanup continues. -Settlement precedes terminal publication so router completion cleanup cannot wait -on its own Done consumer. Cancellation releases intermediate event backpressure; -the settled outcome remains readable even when connection loss prevents publication. -A receipt timeout after a full write preserves the process and pending identity -without redelivery; a blocked write is cancelled and released. -The private adapter permits one input awaiting consumption and at most 63 extra -inputs per Run, preserving the native 64-UUID receipt bound. Durable receipt opt-in -separates bounded writes from native consumption waits; calls without it retain -the router's ten-second deadline. Larger input capacity and interrupted-input -recovery remain separate work. Daemon registration alone does not establish public acceptance. - -Public text/function execution, active input, pending-call cancellation and cold -continuation are accepted for the registered restrictive profile. Environment -provisioning, broader tools/verbosity, complete public Usage, failed image results and -process-loss recovery remain gaps. Managed installation and release publication -remain separate tasks. `make check-cli` also builds -and tests the SDK package, including native output draining; CI selects that check -for changes to the package. Live adapter -acceptance is opt-in and must use a real provider with private credentials. +The [Claude SDK adapter guide](packages/claude-sdk-adapter/README.md#bridge-and-native-lifecycle) +owns the private bridge, native configuration, observation translation and +SDK-specific settlement rules. Use the common Executor/Turn interfaces and the +same Runtime path as every other harness. ### Private Claude SDK runtime artifact -`make build-claude-sdk-runtime` exports the compiled bridge and pinned production -SDK/MCP dependencies, including the native package for the build host, into a -platform/architecture/libc-specific `.tar.gz` and SHA256 file under -`${OAC_DEV_HOME:-$HOME/.oac}/build/claude-sdk-runtime`. `CLAUDE_SDK_BUILD_DIR` -may select another absolute output directory. The production dependency closure -requires Node20 or newer; Node22 is the tested version. Node is operator-supplied -and is not bundled; the bundle is independent of product sources, services and databases. -It does not add Node or SDK assets to the Agents API binaries/image. - -The build validates source manifests with the repository-pinned pnpm frozen -install and compiles into fresh managed staging, never exporting incremental -checkout output. It then uses modern `pnpm deploy` with command-scoped workspace injection -and its dedicated frozen lock. The adapter has no workspace dependencies; keep -that boundary explicit. Do not enable injection globally or replace this with a -custom dependency copier. Export only compiled `dist` and production dependencies; -retain their package metadata, lockfile and licenses. Check dependency links stay -inside the export, pinned SDK/MCP/native versions, native `--version`, and bridge -startup before publishing the archive. Startup with stdin EOF is an import check, -not model execution acceptance. `make check-cli` includes this artifact check. - -Extract the archive into a fresh managed runtime directory on a matching host -and use its absolute `dist/main.js` as the private factory entrypoint. Validate -relocation and real provider cancellation/continuation before accepting an -artifact. Linux x64/glibc with Node22 is the currently exercised platform; -other hosts require their own native acceptance. Do not reuse a bundle across -platforms or libc variants. Automatic Node installation, managed activation and -release publication remain separate work. Operator-configured daemon discovery/registration is supported as -specified above. - -The exported `dist/runtime_check.js` companion is the local readiness contract. -It checks Node20+, installed SDK/MCP/native versions against the package manifest, -contained dependency resolution, native startup, and the exact `dist/main.js` bridge -with stdin EOF. It emits one versioned JSON report without calling a model or -creating Session state. The artifact check reuses this companion and separately -checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` -uses the same operator-supplied Node, entrypoint and environment as execution, -with shared process-group ownership, bounded output and a 15-second deadline -plus bounded cleanup. Both native and bridge probes have five-second limits. -Return unavailable on failed or malformed probes; never forward native diagnostics -or treat local readiness as provider authentication, public capability acceptance -or filesystem isolation. Automatic installation remains separate. - +The [package artifact contract](packages/claude-sdk-adapter/README.md#runtime-artifact) +owns build isolation, dependency export, readiness and relocation checks. +Keep the adapter artifact independent of the Core binary and product sources. ### Harness selection and Agent defaults @@ -3755,20 +3461,75 @@ of the engine. Session creation fixes a node through automatic placement; retain Transient model options are partitioned by engine and must not expose another engine's credentials. Do not infer an engine from a model name or template. -Parsar Agents save default model, harness extension and a typed, non-confidential -`config.environment` selector. The Agent form requires an explicit harness before -saving; existing records without one remain unset in read-only views. The Agent -list shows the configured environment type and harness in both table and compact -layouts. The environment selector is product configuration; -it becomes the official Session `environment`, never part of the protocol Agent. -Only a template reference or supported environment selector is stored, not live -container identity or initialization secrets. An explicitly chosen conversation -environment retains its existing override behavior. Otherwise the first message -uses the Agent defaults. Creating an Agent or opening an empty chat performs no -Core Session or container creation. One conversation/Agent binding freezes the -request on first execution; later messages and observer retries reuse it. Separate -conversations get independent Sessions and environments. Edits affect future -Sessions only. Keep the existing product navigation and direct empty-chat composer. +### Shared Runtime capability preparation + +Resource management retains provider placement, capacity, allocation and Environment +create/renew/reclaim operations. The authenticated Runtime connection carries +initialization, capability preparation and executor operations; it does not implicitly +allocate or destroy compute. Connection loss, executor idle close and Turn cancellation preserve +the workspace and installed snapshot. Resource reclamation coordinates with active +work through its existing owner. + +Core freezes resource versions, metadata and local source selections. Managed +initialization keeps its existing order: initial files, tool configuration, +Skill/Plugin bundle import, packages and setup commands, then directory snapshot +finalization. Every step uses the same daemon and `runtime_prepare` exchange. +Providers only place, create, bootstrap, inspect, renew and reclaim resources; they +do not execute Core initialization commands. The common runner uses only neutral +Environment/Session identity and a Runtime peer, with no Provider, deployment or OS +branch. Harness differences belong to native adapters. Package installation and +setup use the host network; the daemon does not introduce a network sandbox. +A missing authenticated Runtime connection waits before capability initialization +is claimed; an operation whose effect is unknown is not replayed. Self-hosted +capability directories remain in immutable Environment configuration and never +create a managed initialization row or allocation. + +The private `runtime_prepare`/`runtime_prepare_result` exchange carries typed +initial files, configure/npm/python/setup operations, inert Skill/Plugin +archives and finalization selections, with canonical Session and Environment +identities. Files and setup working directories use logical `/workspace` addresses; +setup commands are explicit typed input, while executable selection and physical +destinations remain Runtime-owned. Ordered 64 KiB chunks and SHA-256 receipts +bound each file or archive to 50 MiB, each control frame to 1 MiB and each connection +to one transfer. Initialization and finalization headers carry no file data. Begin, +chunk and commit are never retried; +rejected, failed and unknown effects remain distinct. Runtime owns transfer and +filesystem work through settlement, including disconnect or cancellation. + +The source-selection protocol accepts portable absolute Unix, Windows drive and +UNC paths without consulting Core's filesystem. Runtime interprets and authorizes +local paths. Linux, Windows and macOS use the same preparation implementation, +parser and installed manifest. Runtime paths are chosen by the operator and never +by a transfer request. Installation and package directories default under +`OAC_RUNTIME_HOME`; packaged images can select their existing directories through +explicit Runtime configuration. The manifest binds Session/Environment identities +and the ordered source-selection digest. A completion receipt prevents a deleted +snapshot from being mistaken for first preparation, while filesystem locking +prevents concurrent installation. Invalid, partial or foreign snapshots fail +without deleting data or silently reinstalling. Reconnection loads installed +contents without rereading sources; a new Session captures a fresh snapshot. + +The daemon runs as its launching account and never uses sudo or elevates its +permissions. Only user-directory dependencies are installed during preparation. +System dependencies belong in managed image/template builds or must be installed +by the self-hosted user. `packages.system` is rejected explicitly, including in +templates; it is not ignored or translated into a privileged operation. + +Synchronous executor admission validates the frozen descriptor only. The admitted +asynchronous preparation owner ensures capabilities are ready before invoking the +native factory, including an empty inventory. Resolved Skill paths and MCP declarations +remain Runtime-only fields; engine adapters consume this common result. Reused +Session executors retain their original configuration. Files reads retain their +separate authorization and do not require capability or native execution readiness. + +Self-hosted public input remains `workspace_directory` and optional +`capability_directories`; it does not accept managed archive fields. Its public +installation arrays describe API-managed uploads and stay empty for local directories. +MCP environment variables must come from explicit immutable Runtime tool configuration; +missing variables never fall back to daemon credentials or ambient process variables. +No project-version upgrade or historical manifest compatibility is introduced. +These implementation rules do not establish new real-deployment acceptance; +historical evidence remains limited to its recorded binaries and inputs. ### API-key write provenance @@ -3851,3 +3612,45 @@ terminal transaction: event/input receipt for normal terminal projections, one post-lock database wall-clock sample for forced incomplete Items. Preserve historical terminal nulls and public native completion times. See the [diagnostics contract](contracts/agents-api/session-diagnostics.md). + +## Native daemon and Harness installation + +`oac-daemon install` owns interactive selection and CLI-only installation through +one options/validation path. No installation-options file input is supported. +Persisted installation state and explicitly supplied credential/tool-variable +files serve runtime operation, not a second installer configuration language. +The release bundles pinned Node/npm, native Harnesses and required adapter assets; +registration lives in CLI and native activation/readiness in each adapter's optional +`agent.Installation` descriptor. Core never selects native paths or OS-specific +installation steps. See [native installation](docs/self-hosted-native.md). +An installed daemon discovers and registers only the adapter kinds named by its +verified installation manifest. The host PATH stays available to tools; its other +Harness executables and activation variables cannot extend that installation. +Direct `connect` rejects an installation manifest and directs the operator to +`start`; unmanaged image bootstrap remains available without that manifest. + +All mutations use the installation directory lock. Publish complete checksum-verified +components from staging, then commit configuration after native readiness passes. +Re-running with the same connection settings adds selected Harnesses and validates +existing contents. Never overwrite, upgrade, auto-repair or migrate installed +components. Missing, modified, wrong-platform or incompatible content is an explicit +error. A partial addition must preserve the old configuration and allow reuse of +complete components; it must not remove previous files or data. + +Installers run as the current user in writable directories. Native subprocess +diagnostics must not expose sensitive parameters or environment values. Adapter +readiness checks receive the installer cancellation context and must reap owned +processes before returning after interruption. Readiness, +authenticated connection and model configuration are separate reported facts. +Starting execution must not download or install Harnesses. Ordinary stop/reconnect +must preserve capability snapshots and native Session state. + +`scripts/build-native-installer.mjs` packages native inputs, validates pins/startup +and hashes every component file. It uses contained regular files and rejects +escaping links. Claude's dedicated frozen `pnpm deploy` export is reified with +the hoisted linker for this distribution before contained links are flattened; +the original standalone Claude archive contract remains unchanged. The native +installer workflow builds and tests on Linux, macOS and Windows, including actual +installation, addition/reuse, missing arguments and unsupported Windows MiniMax. +Native CI startup checks do not replace real model execution evidence or claim +manual Windows acceptance. Heavy builds belong on remote servers or CI. diff --git a/Makefile b/Makefile index 90c1b28c2..23c43bda5 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-executor check-agents-executor 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-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 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.' -check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-mcode-harness check-agents-executor +check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-mcode-harness @printf 'OpenAgentCore checks passed.\n' .PHONY: check-names @@ -44,6 +44,11 @@ check-sqlc: check-go: go test ./apps/parsar-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 + build-daemon: @set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/daemon"; \ [[ "$$output" == /* ]] || { echo 'Daemon output directory must be absolute' >&2; exit 1; }; \ @@ -60,7 +65,6 @@ check-agents-api: build-agents-api # 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/runtime/initialize_receipt_test.py PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/e2b/managed_init_test.py docker-build-agents-api: @@ -73,7 +77,6 @@ node-deps: pnpm install --frozen-lockfile check-claude-sdk: node-deps - python3 services/agents-api/deploy/claude/shell_prefix_test.py pnpm --filter @parsar/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime @@ -92,12 +95,6 @@ check-mcode-harness: @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh -build-agents-executor: - ./scripts/build-agents-executor.sh - -check-agents-executor: - ./scripts/check-agents-executor.sh - build-agents-runtime: ./scripts/build-agents-runtime.sh @@ -132,6 +129,8 @@ check-distribution: go test ./services/core-console -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/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 scripts/build-core-console.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh ./scripts/build-core-console.sh @@ -151,3 +150,8 @@ check-e2b-provider: .PHONY: check-docs check-docs: node-deps pnpm check:docs + +# These packages are also exercised by check-agents-api in the full gate. +.PHONY: check-sandbox-provider-contract +check-sandbox-provider-contract: + go test ./services/agents-api/internal/sandbox/... -count=1 diff --git a/README.md b/README.md index e3d6d192a..550d7376f 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,58 @@ # OpenAgentCore -![One core. Many agents. Open infrastructure for AI agents.](docs/assets/openagentcore-banner.png) +[English](README.md) | [简体中文](README.zh-CN.md) -**Open-source Agents API infrastructure, with your choice of native harness.** +![OpenAgentCore](docs/assets/openagentcore-banner.png) -Run Codex, Claude Code and MiniMax Code behind the OpenAI Agents API, on machines you -control. Core serves the API and runs each Session in a sandbox; Web is the -administrator console that issues the keys applications call Core with. +Run native Codex, Claude Code and MiniMax Code through one Agents API. Core owns +Sessions and execution state; a daemon prepares capabilities and runs the selected +Harness on a managed sandbox or a machine you connect yourself. Web is the +administrator console. -## Get started +## Quick start -1. **Install Core and Web with one command.** On a Linux amd64 host with Docker, - download a release bundle and run its installer. The repository is internal for - now, so sign in to GitHub first. Pick `` from - `gh release list --repo MiniMax-AI/parsar-core`: +1. [Install Core and Web](docs/getting-started/install.md) on a Linux host. The guide + covers prerequisites, release download, local trials and HTTPS setup. +2. Sign in to Web with the installer-created Core key. Configure a model provider, + create a Project and issue its API key. For managed execution, + [add a node or configure E2B](docs/getting-started/nodes.md). +3. [Run your first Session](docs/getting-started/quickstart.md) with the pinned Python + SDK. The walkthrough checks authentication, submits a task and waits for its result. - ```sh - gh auth login - gh release download --repo MiniMax-AI/parsar-core --pattern '*-linux-amd64-offline.tar.gz*' - sha256sum -c oac--linux-amd64-offline.tar.gz.sha256 - tar -xzf oac--linux-amd64-offline.tar.gz - cd oac--linux-amd64 - ./install.sh --public-url https://core.example - ``` +To execute on your own Linux, macOS or Windows machine, follow the +[self-hosted Runtime guide](docs/getting-started/self-hosted.md). - Put your HTTPS reverse proxy in front first, or install without `--public-url` for - a local trial and set it later. See the - [installation guide](docs/getting-started/install.md). If you're reading this on - GitHub, follow the docs inside the downloaded bundle (`README.md` and `docs/`) - instead: they match its installer, while GitHub shows the current source. -2. **Sign in to Web with the Core key**, from `~/.oac/core/secrets/core.key`. On - **System**, set a default model; on **Projects and keys**, create a project and - issue a key. See [Sign in to Web](docs/getting-started/install.md#sign-in-to-web). -3. **Add a node by pasting one command.** On **Nodes**, choose **Add node**, then - **Generate command**, and run the command on a Linux host with sudo. See - [Nodes](docs/getting-started/nodes.md). +## How it fits together -Web's **Overview** tracks these steps, and the first Session, in its **Getting started** -checklist; they can be done in any order. - -Applications then set `OPENAI_BASE_URL` to `https://core.example/v1` and -`OPENAI_API_KEY` to the Project API key, and use the official OpenAI SDK; see -[Call the API](docs/getting-started/quickstart.md). - -## What you get - -- **One API, several harnesses.** Core implements part of the pinned OpenAI Agents API - (`openai-python` 3.13.0, `agents=v1`). Each Session runs a native harness, Codex, - Claude Code or MiniMax Code, which keeps its own model and tool loop. Core's additions - live only in `x_agents_core`: the harness and the model provider. -- **Durable execution.** Core owns Agents, Sessions, Turns, Items, environments, files - and credentials, in its own PostgreSQL database. -- **Your sandboxes.** Core-hosted Sessions run in sandboxes on your nodes (Docker or - microsandbox microVMs) or on E2B. Applications can also connect their own machines as - self-hosted executors. -- **A console for administrators.** Web signs in with the Core key, issues Project API - keys, adds nodes, sets default models and shows metrics and Session history. It - never runs Agents on anyone's behalf. -- **Independent of the Parsar product.** Core needs no product service or database. - -A passing workflow does not establish complete OpenAI Agents API compatibility; the -[protocol coverage](contracts/agents-api/README.md) records what is qualified and the -native differences. - -## Documentation - -| Page | Covers | -| --- | --- | -| [Install Core and Web](docs/getting-started/install.md) | Prerequisites, download, installer options, HTTPS and the reverse proxy, first sign-in | -| [Nodes](docs/getting-started/nodes.md) | Adding, removing and troubleshooting nodes | -| [Self-hosted executors](docs/getting-started/self-hosted.md) | Connecting an application's own machine to a Session | -| [Operations](docs/getting-started/operations.md) | The `oac` command, the Core key, backups, upgrades, troubleshooting | -| [Configuration reference](docs/configuration.md) | Every setting in `config.json` and in Web | -| [Call the API](docs/getting-started/quickstart.md) | The application developer's quickstart | -| [API reference](docs/api/README.md) | The `/v1`, `/core/v1` and `/api/v1` namespaces | -| [Nodes and sandbox backends: operator reference](services/agents-api/HOSTED-SANDBOX-MANAGER.md) | Node protocol, manual registration, placement, reset | -| [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences | -| [Landing page source](site/index.html) | The public site | - -### Maintainers and advanced deployments - -Building distributions, producing releases and running Core alone, without Web or the -installer, are described in [Maintainers and advanced deployments](docs/maintainers.md). -The standalone Core archive and container are not an installation path for new users. - -## Develop and build - -The repository includes the API, its independent PostgreSQL migrations, daemon, -Runtime and provider adapters, clients, Web console and distribution tools. It has no -Parsar product service, product database or business-user dependency. - -```sh -make build-agents-api -make build-daemon -pnpm dev:web +```text +Application → Core API → common daemon protocol → Runtime → native Harness + │ + └→ Sandbox Provider → create / bootstrap / reclaim Environment ``` -Use the toolchain pinned in `go.mod`, Node 22 and pnpm 10.30.3. Build output goes under -`~/.oac/build/`. See the [service guide](services/agents-api/README.md), -[Docker Runtime](services/agents-api/deploy/codex/README.md), -[microsandbox provider](services/agents-api/deploy/microsandbox/README.md) and -[Web development guide](docs/web/README.md). - -Core-managed and user-managed environments reuse the colocated daemon, native harness, -tools and workspace. Caller-owned E2B provisioning uses the official SDK; -deployment-managed E2B uses the configured sandbox provider. For caller-owned -environments, the returned `remote_url` connects our daemon, not `exec-server`. See the -[Runtime enrollment guide](services/agents-api/README.md#user-managed-runtime-enrollment). - -Read [CONTRIBUTING.md](CONTRIBUTING.md) before developing. Historical source-copy -provenance is retained in [provenance/README.md](provenance/README.md). Existing Go -import paths resolve inside this repository and do not require the product repo. -Third-party native packages remain pinned build dependencies. +Managed Providers supply Linux environments. Self-hosted daemons run on Linux, +macOS and Windows, subject to the selected Harness's +[platform support](docs/self-hosted-native.md#platforms-and-prerequisites). The daemon uses +its starting account's permissions; isolation belongs to an outer sandbox. +Releasing an executor does not destroy its Environment. -## Validate +The public contract follows the pinned OpenAI Agents API. See the +[coverage record](contracts/agents-api/README.md) for supported operations and +native differences. Core is independent of the Parsar product and its database. -On Linux with Go, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 (including -rustfmt/Clippy), OpenSSL development libraries, Chrome for Playwright, and a -dedicated test PostgreSQL: - -```sh -export OAC_TEST_DATABASE_URL='postgres://.../oac_core_tests?sslmode=disable' -make check -``` +## Documentation -Install the pinned Playwright browser on a fresh validation host with -`pnpm exec playwright install --with-deps chrome`. Fixture acceptance uses -loopback ports `18092` and `4174`; set `AGENTS_FIXTURE_PORT` and -`AGENTS_WEB_PORT` to unused ports when either default is occupied. +All documentation is in English; both README editions use the same sources. -The full gate requires the test database rather than silently skipping persistence -tests. Native model/provider fixtures remain explicit, credential-dependent -acceptance checks; see the [coverage ledger](contracts/agents-api/README.md). -The Rust gate covers only the retained directory/write/export helpers; the former -separate Codex harness gate and remote probe suite are retired. -Importing existing implementations does not establish additional protocol coverage. +| Start here | Purpose | +| --- | --- | +| [Documentation index](docs/getting-started/README.md) | Installation, usage and administration reading paths | +| [User guide](docs/user-guide.md) | Sessions, Skills/Plugins/MCP, files, cancellation and recovery | +| [Configuration](docs/configuration.md) | Operator settings and model configuration | +| [API reference](docs/api/README.md) | Application, administration and machine APIs | +| [Developer guide](docs/development.md) | Repository map, local setup, builds and validation | +| [Add a Harness](contracts/agents-api/harness-onboarding.md) | Adapter implementation and acceptance | +| [Core–Runtime protocol](docs/runtime-protocol.md) | Lifecycle, capability preparation and execution | +| [Add a Sandbox Provider](docs/sandbox-provider.md) | Environment creation and resource ownership | + +Before changing code, read [the contributor rules](CONTRIBUTING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 000000000..34afd29ec --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,53 @@ +# OpenAgentCore + +[English](README.md) | [简体中文](README.zh-CN.md) + +![OpenAgentCore](docs/assets/openagentcore-banner.png) + +通过一套 Agents API 运行原生 Codex、Claude Code 和 MiniMax Code。Core 管理 Session +和执行状态;daemon 在托管沙箱或用户连接的机器上准备能力、运行所选 Harness。 +Web 提供管理员控制台。 + +## 快速开始 + +1. 在 Linux 主机上[安装 Core 和 Web](docs/getting-started/install.md)。安装指南包含环境要求、 + 发行包下载、本地试用和 HTTPS 配置。 +2. 用安装器生成的 Core key 登录 Web,配置模型提供方,创建 Project 并签发应用 API key。 + 使用托管环境时,先[添加节点或配置 E2B](docs/getting-started/nodes.md)。 +3. 跟随[第一个 Session 示例](docs/getting-started/quickstart.md),安装指定版本的 Python SDK, + 验证访问、提交任务并等待执行结果。 + +要在自己的 Linux、macOS 或 Windows 机器上执行任务,请阅读 +[自托管 Runtime 指南](docs/getting-started/self-hosted.md)。 + +## 组件关系 + +```text +应用 → Core API → 统一 daemon 协议 → Runtime → 原生 Harness + │ + └→ Sandbox Provider → 创建 / 引导 / 回收 Environment +``` + +托管 Provider 提供 Linux 环境;自托管 daemon 支持 Linux、macOS 和 Windows, +具体组合见 [Harness 平台支持表](docs/self-hosted-native.md#platforms-and-prerequisites)。 +daemon 使用启动账户的权限,隔离由外层沙箱负责。释放执行器不会销毁 Environment。 + +公开接口遵循固定版本的 OpenAI Agents API,已支持的操作和原生引擎差异见 +[协议覆盖记录](contracts/agents-api/README.md)。 + +## 文档 + +正文统一使用英文;中英文 README 指向同一套文档。 + +| 入口 | 内容 | +| --- | --- | +| [文档目录](docs/getting-started/README.md) | 安装、使用和运维的阅读路径 | +| [使用指南](docs/user-guide.md) | Session、Skill/Plugin/MCP、文件、取消与恢复 | +| [配置参考](docs/configuration.md) | 部署设置和模型配置 | +| [API 参考](docs/api/README.md) | 应用、管理与机器接口 | +| [开发指南](docs/development.md) | 仓库结构、开发环境、构建与验证 | +| [接入 Harness](contracts/agents-api/harness-onboarding.md) | 适配器实现与验收 | +| [Core–Runtime 协议](docs/runtime-protocol.md) | 生命周期、能力准备与执行 | +| [接入 Sandbox Provider](docs/sandbox-provider.md) | 环境创建与资源管理 | + +修改代码前请阅读[贡献规范](CONTRIBUTING.md)。 diff --git a/apps/docs/FOLLOW-UP.md b/apps/docs/FOLLOW-UP.md deleted file mode 100644 index 6da02fefd..000000000 --- a/apps/docs/FOLLOW-UP.md +++ /dev/null @@ -1,24 +0,0 @@ -# Feature integration follow-up - -This candidate documents durable reset, retained Core generations and verified -same-team E2B online changes at source `b9b13b186089b317db271ab83b10f86b48168f66`. -Node changes still require zero held resources until the node protocol is complete. -The Core error-envelope foundation is included; later configuration validators and -diagnostic endpoints are not. Do not replace current procedures with planned behavior -before those features land. - -| Feature dependency | Site pages to reconcile | Source and verification work | -| --- | --- | --- | -| Final Web generation controls (design 50 PR-W) | configure, hosted-providers, troubleshooting, execution-model | Reconcile the actual Web workflow after node online changes are qualified; keep target preparation separate from serving readiness. | -| Node update protocol (design 50 PR-N) | hosted-providers, troubleshooting, API reference/machine | Explain the implemented update command, old-node handling and ownership preservation. Do not claim an update command now. | -| Later configuration/error changes (design 40 and remaining installer work) | install, configure, troubleshooting, quickstart | Regenerate current configuration fields and revise diagnostics against merged code; keep secrets out of examples. | -| Final feature assembly | every Chinese reading note, execution-model diagram, all generated references | Review translations against regenerated English, verify source provenance and links, run contract/route/copy/fact checks, typecheck/build, local browser review and the repository gate. | - -English guides are rendered from repository sources. Chinese pages are explicitly -labelled reading notes, not complete translations; expand them only against the final -source procedures. API tag pages use the default-language fallback. Release publication, -remote deployment and preview replacement are outside this reconciliation task. - -The source Web guide still carries a pre-rename screenshot. This site copies the -approved current Web onboarding Monitor image instead, with its own source hash; -refresh that mapping when the canonical guide image is updated. diff --git a/apps/docs/README.md b/apps/docs/README.md index a526c4fa0..4ea9e6e2f 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -1,8 +1,7 @@ # OpenAgentCore documentation site A separate Next.js/Fumadocs documentation app. It starts no Core, database or Runtime. -English is served at `/`; Chinese reading notes at `/zh`, with English fallback for -API tag pages. The existing preview is not part of this checkout. +All documentation is served in English at `/`. The existing preview is not part of this checkout. The application reference reads the upstream-constrained public schema. Administration and machine references read their own local generated contracts. All three repository @@ -13,8 +12,8 @@ prose, parameters, response schemas and credential boundaries. Examples use a re domain. The reference is read-only and must not collect keys or send execution requests. English guides are generated from the canonical repository docs, not maintained as a -second installation manual. Edit those sources first, update the Chinese reading notes -in [guides.json](scripts/guides.json), then regenerate. Source/output hashes fail when +second manual. Edit those sources first, register pages in +[guides.json](scripts/guides.json), then regenerate. Source/output hashes fail when copies drift; operation and route checks detect missing or mixed API surfaces. From the repository root: @@ -36,13 +35,12 @@ to keep site packages out of the gate. `make check-docs` runs the focused gate and is included in `make check`. After starting the built site, run `pnpm --dir apps/docs check:site http://127.0.0.1:4275` and inspect -both locales in a browser with `pnpm --dir apps/docs check:browser http://127.0.0.1:4275`. +the English guides in a browser with `pnpm --dir apps/docs check:browser http://127.0.0.1:4275`. The check uses installed Playwright Chromium (or `DOCS_BROWSER_EXECUTABLE`), checks for credential/request controls and external traffic, and accepts only a local origin. Set `DOCS_SITE_ORIGIN` only when preparing actual publication. Generation links to the recorded source revision; update that revision after rebasing onto later canonical guide changes. Never point samples at an actual deployment by default. -[Feature follow-up inventory](FOLLOW-UP.md) lists the reset and online-generation -paragraphs that must be reconciled after those implementations land. This candidate -cannot establish final documentation acceptance for those unfinished features. +Current supported behavior and acceptance evidence live in the +[contract index](../../contracts/agents-api/README.md). diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx similarity index 53% rename from apps/docs/app/[lang]/[[...slug]]/page.tsx rename to apps/docs/app/[[...slug]]/page.tsx index 941dd8097..ce9b7c35f 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -4,16 +4,9 @@ import { ImageZoom } from "fumadocs-ui/components/image-zoom" import defaultMdxComponents from "fumadocs-ui/mdx" import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page" import { APIPage } from "@/components/api-page" -import { LocaleLink } from "@/components/locale-link" -import { i18n, type Lang } from "@/lib/i18n" -import { docsAlternates } from "@/lib/site" +import { absoluteDocsUrl } from "@/lib/site" import { source } from "@/lib/source" -function asLang(value: string): Lang { - if (!(i18n.languages as readonly string[]).includes(value)) notFound() - return value as Lang -} - function pageSlug(slug?: string[]) { return slug ?? [] } @@ -21,11 +14,10 @@ function pageSlug(slug?: string[]) { export default async function DocsPageRoute({ params, }: { - params: Promise<{ lang: string; slug?: string[] }> + params: Promise<{ slug?: string[] }> }) { - const { lang: rawLang, slug } = await params - const lang = asLang(rawLang) - const page = source.getPage(pageSlug(slug), lang) + const { slug } = await params + const page = source.getPage(pageSlug(slug)) if (!page) notFound() const MDX = page.data.body @@ -42,11 +34,7 @@ export default async function DocsPageRoute({ // Screenshots open at full size; the prose column is too narrow to // read a console capture at 1:1. img: ImageZoom, - // Keep internal navigation inside the reader's language. Pages are - // written with locale-free links — `[Configure](/configure)` — and - // the prefix is added here, because a translated page would - // otherwise silently hand the reader the default language. - a: (props) => , + }} /> @@ -61,18 +49,15 @@ export function generateStaticParams() { export async function generateMetadata({ params, }: { - params: Promise<{ lang: string; slug?: string[] }> + params: Promise<{ slug?: string[] }> }): Promise { - const { lang: rawLang, slug } = await params + const { slug } = await params const slugs = pageSlug(slug) - const page = source.getPage(slugs, asLang(rawLang)) + const page = source.getPage(slugs) if (!page) return {} return { title: page.data.title, description: page.data.description, - // Declare the translations as alternates of one article, and name the - // canonical URL, so search engines do not index the English and Chinese - // pages as duplicates of each other. - alternates: docsAlternates(slugs), + alternates: { canonical: absoluteDocsUrl(page.url) }, } } diff --git a/apps/docs/app/[lang]/docs-providers.tsx b/apps/docs/app/[lang]/docs-providers.tsx deleted file mode 100644 index f7b40a7e4..000000000 --- a/apps/docs/app/[lang]/docs-providers.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client" - -import Image from "next/image" -import Link from "next/link" -import { useParams, usePathname, useRouter } from "next/navigation" -import { FrameworkProvider, type Framework } from "fumadocs-core/framework" -import { RootProvider } from "fumadocs-ui/provider/base" -import { useCallback, type ReactNode } from "react" -import { i18n, type Lang } from "@/lib/i18n" - -const locales = [ - { name: "English", locale: "en" }, - { name: "中文", locale: "zh" }, -] - -const translations = { - en: { - toc: "On this page", - chooseLanguage: "Choose language", - nextPage: "Next page", - previousPage: "Previous page", - }, - zh: { - toc: "本页目录", - chooseLanguage: "选择语言", - nextPage: "下一页", - previousPage: "上一页", - }, -} as const - -function isLocale(segment: string | undefined): boolean { - return Boolean(segment) && (i18n.languages as readonly string[]).includes(segment as string) -} - -function pathForLocale(pathname: string, locale: string) { - const segments = pathname.split("/").filter(Boolean) - // Drop a leading locale segment, whichever language it names — matching on a - // literal "en"/"zh" pair here would silently break when a language is added. - if (isLocale(segments[0])) segments.shift() - const rest = segments.join("/") - if (locale === i18n.defaultLanguage) return rest ? `/${rest}` : "/" - return rest ? `/${locale}/${rest}` : `/${locale}` -} - -export function DocsProviders({ lang, children }: { lang: Lang; children: ReactNode }) { - const router = useRouter() - const pathname = pathForLocale(usePathname(), lang) - // Navigation must use the public path during both SSR and hydration. - const usePublicPathname = useCallback(() => pathname, [pathname]) - - return ( - - router.push(pathForLocale(pathname, locale)), - }} - > - {children} - - - ) -} diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx deleted file mode 100644 index 91e648c52..000000000 --- a/apps/docs/app/[lang]/layout.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import "../global.css" -import type { Metadata } from "next" -import { notFound } from "next/navigation" -import type { ReactNode } from "react" -import { DocsLayout } from "fumadocs-ui/layouts/docs" -import { baseOptions } from "../layout.config" -import { source } from "@/lib/source" -import { i18n, type Lang } from "@/lib/i18n" -import { DocsProviders } from "./docs-providers" - -export const metadata: Metadata = { - title: { - template: "%s | OpenAgentCore Docs", - default: "OpenAgentCore Docs", - }, - description: "Documentation for OpenAgentCore.", -} - -export function generateStaticParams() { - return i18n.languages.map((lang) => ({ lang })) -} - -export default async function LangLayout({ - params, - children, -}: { - params: Promise<{ lang: string }> - children: ReactNode -}) { - const { lang: rawLang } = await params - if (!(i18n.languages as readonly string[]).includes(rawLang)) notFound() - const lang = rawLang as Lang - - return ( - - {/* Browser extensions may add attributes before React hydrates the body. */} - - - - {children} - - - - - ) -} diff --git a/apps/docs/app/docs-providers.tsx b/apps/docs/app/docs-providers.tsx new file mode 100644 index 000000000..e70421d8b --- /dev/null +++ b/apps/docs/app/docs-providers.tsx @@ -0,0 +1,17 @@ +"use client" + +import Image from "next/image" +import Link from "next/link" +import { useParams, usePathname, useRouter } from "next/navigation" +import { FrameworkProvider, type Framework } from "fumadocs-core/framework" +import { RootProvider } from "fumadocs-ui/provider/base" +import type { ReactNode } from "react" + +export function DocsProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 40503ed92..cc12ffffa 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -50,10 +50,10 @@ body { } /* Give every table column a readable floor. The minimum content width of a - column that holds only Chinese text is about one character, while a column + column with short words is about one character, while a column holding a parameter such as `OAC_DATABASE_URL` cannot shrink below that parameter. The automatic layout satisfies the wider columns first and - can crush the Chinese one to about four characters per line. Five columns of + can crush a short-label column to a few characters per line. Five columns of 7rem still fit the narrowest content area the layout produces. */ .prose table :is(th, td) { min-width: 7rem; diff --git a/apps/docs/app/layout.config.tsx b/apps/docs/app/layout.config.tsx index 68f3bf2c9..472764f55 100644 --- a/apps/docs/app/layout.config.tsx +++ b/apps/docs/app/layout.config.tsx @@ -1,7 +1,6 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared" export const baseOptions: BaseLayoutProps = { - i18n: true, nav: { title: "OpenAgentCore Docs", }, diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx new file mode 100644 index 000000000..a14a4972e --- /dev/null +++ b/apps/docs/app/layout.tsx @@ -0,0 +1,24 @@ +import "./global.css" +import type { Metadata } from "next" +import type { ReactNode } from "react" +import { DocsLayout } from "fumadocs-ui/layouts/docs" +import { baseOptions } from "./layout.config" +import { source } from "@/lib/source" +import { DocsProviders } from "./docs-providers" + +export const metadata: Metadata = { + title: { template: "%s | OpenAgentCore Docs", default: "OpenAgentCore Docs" }, + description: "Documentation for OpenAgentCore.", +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ) +} diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index fa0686507..50fb7d8d0 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -1,69 +1,11 @@ -"use client" - import Link from "next/link" -import { usePathname } from "next/navigation" -import { i18n } from "@/lib/i18n" - -const COPY = { - en: { - title: "Page not found", - body: "The page you are looking for does not exist, or it moved when the manual was reorganised.", - back: "Back to the manual", - }, - zh: { - title: "页面不存在", - body: "你访问的页面不存在,或已随手册改版移动到别处。", - back: "返回手册首页", - }, -} as const -/** - * 404 for the whole site. Two things about its placement are deliberate. - * - * It lives at the app root, not at `app/[lang]/`. A `not-found` boundary inside - * the segment that also holds the root layout is never compiled as a boundary, - * so the version that used to sit there was dead code: every 404 got Next's - * built-in notice instead. - * - * It is a client component that reads the language from the URL. Taking the - * language from the request on the server — `headers()`, or any other - * request-scoped API — opts *every sibling route* out of static generation, - * because the boundary belongs to the `[lang]/[[...slug]]` render tree. That is - * not a small regression: it made all 102 docs pages re-render per request, and - * the generated API reference pages carry whole OpenAPI surfaces, so a single - * navigation took two to three seconds instead of thirty milliseconds. - * - * The cost of the trade is that this notice arrives with hydration rather than - * in the first response, and outside the docs chrome: without a root layout - * Next renders a root `not-found` in its own shell. A 404 is `noindex` and a - * dead end, so paying there to keep every real page prerendered is the right - * way round. - * - * If this moves back to the server, `pnpm build` must still write prerendered - * `.html` files under `.next/server/app/en/` — the build log reporting - * `● (SSG)` is not sufficient, it says that either way. - */ export default function NotFound() { - const pathname = usePathname() - const first = pathname.split("/").filter(Boolean)[0] - const locale = - first && (i18n.languages as readonly string[]).includes(first) - ? first - : i18n.defaultLanguage - - const text = COPY[locale as keyof typeof COPY] ?? COPY.en - const home = locale === i18n.defaultLanguage ? "/" : `/${locale}` - return (
-

{text.title}

-

{text.body}

- - {text.back} - +

Page not found

+

The requested documentation page does not exist.

+ Back to the manual
) } diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 0600f3c8f..bb002fb83 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -1,48 +1,7 @@ import type { MetadataRoute } from "next" -import { i18n } from "@/lib/i18n" import { absoluteDocsUrl } from "@/lib/site" import { source } from "@/lib/source" -/** - * Sitemap for the manual, built from Fumadocs' page list at build time. - * - * One entry per logical page: every language variant of a page is declared as - * an hreflang alternate of the same entry, so a search engine reads the English - * and Chinese pages as one article in two languages rather than as duplicates. - */ export default function sitemap(): MetadataRoute.Sitemap { - // Collapse the language variants into one group per slug. - const bySlug = new Map>() - - for (const { language, pages } of source.getLanguages()) { - for (const page of pages) { - const slugKey = page.slugs.join("/") - const languages = bySlug.get(slugKey) ?? new Map() - languages.set(language, page.url) - bySlug.set(slugKey, languages) - } - } - - const entries: MetadataRoute.Sitemap = [] - - for (const languages of bySlug.values()) { - // Canonical is the default language when it exists, otherwise the first - // translation available. - const canonicalRelative = - languages.get(i18n.defaultLanguage) ?? languages.values().next().value - if (!canonicalRelative) continue - - const alternates: Record = {} - for (const [lang, relative] of languages) { - alternates[lang] = absoluteDocsUrl(relative) - } - alternates["x-default"] = absoluteDocsUrl(canonicalRelative) - - entries.push({ - url: absoluteDocsUrl(canonicalRelative), - alternates: { languages: alternates }, - }) - } - - return entries -} \ No newline at end of file + return source.getPages().map(page => ({ url: absoluteDocsUrl(page.url) })) +} diff --git a/apps/docs/components/locale-link.tsx b/apps/docs/components/locale-link.tsx deleted file mode 100644 index 136081f81..000000000 --- a/apps/docs/components/locale-link.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { AnchorHTMLAttributes, ReactNode } from "react" -import { i18n } from "@/lib/i18n" -import { prefixLocale } from "@/lib/locale-link" - -type LocaleLinkProps = AnchorHTMLAttributes & { - lang: string - children?: ReactNode -} - -/** - * MDX anchor override for the docs routes. - * - * The active language is passed in by the route, which knows it from the URL, - * so no client-side context is required and this stays a server component. It - * renders the same plain anchor Fumadocs uses by default, after adding the - * locale prefix — see `prefixLocale`. - */ -export function LocaleLink({ lang, href, children, ...rest }: LocaleLinkProps) { - return ( - - {children} - - ) -} diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 351e5a328..79e5d2430 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -22,7 +22,7 @@ The browser uses the console's own origin and signs in with the | `GET /console/auth` | No body | `200 {"mode":"login"}` or `200 {"mode":"authenticated"}` | | `POST /console/auth/login` | `Content-Type: application/json`, body `{"core_key":"…"}`; other members are rejected | `200 {"mode":"authenticated"}` and an HttpOnly, SameSite=Strict session cookie (Secure over HTTPS) | | `POST /console/auth/logout` | No credential payload | `200 {"mode":"login"}`; clears the cookie and the server-side session | -| `GET /console/config` | Signed-in session | `node_installer`, `node_installer_sha256`, `self_hosted_installer`, `self_hosted_installer_sha256`, and `node_artifacts`: the providers (`docker`, `microsandbox`) whose node assets this console holds | +| `GET /console/config` | Signed-in session | `node_installer`, `node_installer_sha256`, `node_artifacts`: the providers (`docker`, `microsandbox`) whose node assets this console holds | Sign-in errors use the console's `{"error": "…"}` envelope: 400 for a malformed body, 401 for a wrong key, 415 for a non-JSON body, 429 with `Retry-After` when @@ -49,9 +49,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 @@ -156,7 +156,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. @@ -165,7 +165,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/api/web-management.md) diff --git a/apps/docs/content/docs/admin-api.zh.mdx b/apps/docs/content/docs/admin-api.zh.mdx deleted file mode 100644 index a35483936..000000000 --- a/apps/docs/content/docs/admin-api.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Core administration API · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -管理员脚本通过 Core 的回环端口调用 /core/v1,并使用 Core key。公网入口的 /core/v1 由 Web 管理会话处理;不能把它当成应用 API 或机器连接的代理。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/admin-api)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 1f5432c42..96c60c167 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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,20 +22,20 @@ 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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). 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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. | +| 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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). 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. MiniMax functions and self-hosted image workflows are 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. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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. | +| 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. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 recovery contract @@ -52,7 +52,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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. @@ -61,8 +61,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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. | +| 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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. | +| 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 @@ -121,4 +121,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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/agents-and-tools.zh.mdx b/apps/docs/content/docs/agents-and-tools.zh.mdx deleted file mode 100644 index 5ed1f6f95..000000000 --- a/apps/docs/content/docs/agents-and-tools.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Agents, tools and harnesses · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -保存 Agent 只保存配置,不启动执行。工具声明由所选原生 harness 执行;Codex、Claude Code 与 MiniMax Code 的能力和限制不同。x_agents_core 保留原有扩展字段名称。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/agents-and-tools)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/api-reference/environment-templates.mdx b/apps/docs/content/docs/api-reference/environment-templates.mdx index 5e68bd5c2..32e05af1a 100644 --- a/apps/docs/content/docs/api-reference/environment-templates.mdx +++ b/apps/docs/content/docs/api-reference/environment-templates.mdx @@ -20,13 +20,15 @@ _openapi: 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, - system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs - and workspace-contained capability directories. Omitted/null network + 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. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + 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. - content: >- Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 2aa51289f..400565ff7 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -51,26 +51,27 @@ _openapi: startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and - native OAuth login remain unsupported. The self_hosted profile - requires Codex, an absolute workspace_directory and empty - capability_directories, with optional non-deferred function tools and - HTTP MCP using service origin, optionally authenticated by the - attached Vault rules. Remote MCP and remote Bearer authentication each - require separately advertised combination support; old peers cannot - receive unsupported work. Omitted/null capability_directories use the - empty-list default; self_hosted requires configured execution plus - executor registry. Claude SDK currently requires medium verbosity and - object-root function schemas. It supports anonymous or attached - static-bearer service-origin HTTP MCP on none with boolean required - and separately advertised MCP/bearer/required runtime support. - Required servers must be connected before the first native input is - released; pending or failed startup rejects execution. The shared - Vault selection and immutable binding rules apply; unsupported native - labels/tool names reject before persistence. An attached Vault with no - matching credential may remain anonymous; missing keys or failed - credential lookup/decryption never fall back to anonymous execution. - Omitted stream defaults to false; stream and agent_id cannot be null. - Metadata may be null; non-string values and limit violations return + native OAuth login remain unsupported. The self_hosted profile uses a + qualified native harness, a clean absolute workspace_directory and + optional absolute local capability_directories prepared by Runtime, + with optional non-deferred function tools and HTTP MCP using service + origin, optionally authenticated by the attached Vault rules. Remote + MCP and remote Bearer authentication each require separately + advertised combination support; old peers cannot receive unsupported + work. Omitted/null capability_directories use the empty-list default; + self_hosted requires configured execution plus executor registry. + Claude SDK currently requires medium verbosity and object-root + function schemas. It supports anonymous or attached static-bearer + service-origin HTTP MCP on none with boolean required and separately + advertised MCP/bearer/required runtime support. Required servers must + be connected before the first native input is released; pending or + failed startup rejects execution. The shared Vault selection and + immutable binding rules apply; unsupported native labels/tool names + reject before persistence. An attached Vault with no matching + credential may remain anonymous; missing keys or failed credential + lookup/decryption never fall back to anonymous execution. Omitted + stream defaults to false; stream and agent_id cannot be null. Metadata + may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and @@ -125,22 +126,24 @@ _openapi: enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, - system/npm/Python packages and ordered setup commands use the shared + npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. - Initial inline and tenant-owned file_id files freeze encrypted bytes - before provisioning, then install through the common Core lifecycle - before native execution or live Files access. With a template - reference, omitted/null files, env, packages and setup_commands - inherit. Non-null files and command lists replace; env overlays by - key; each package manager inherits on omission/null and otherwise - replaces its list. Empty lists clear their selected field. - Tenant-owned environment_template_id references inherit omitted/null - network and allow only narrowing overrides. Inline hosted network:null - retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective - configuration; template updates/deletion do not alter Session - snapshots or same-intent creation retries. Inline or tenant-owned - skill_reference Skills share initialization. Templates preserve + System dependencies must be preinstalled in the sandbox image or + template, or on the host machine; packages.system is rejected. Initial + inline and tenant-owned file_id files freeze encrypted bytes before + provisioning, then install through the common Core lifecycle before + native execution or live Files access. With a template reference, + omitted/null files, env, packages and setup_commands inherit. Non-null + files and command lists replace; env overlays by key; each package + manager inherits on omission/null and otherwise replaces its list. + Empty lists clear their selected field. Tenant-owned + environment_template_id references inherit omitted/null network and + allow only narrowing overrides. Inline hosted network:null retains the + enabled default; updating a Template with network:null resets its + saved policy to enabled. Core freezes effective configuration; + template updates/deletion do not alter Session snapshots or + same-intent creation retries. Inline or tenant-owned skill_reference + Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 7b44799a4..b37bd0824 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.zh.mdx b/apps/docs/content/docs/bootstrap-projects-keys.zh.mdx deleted file mode 100644 index 1c1dd494e..000000000 --- a/apps/docs/content/docs/bootstrap-projects-keys.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Sign in and issue application keys · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -使用 Core key 登录 Web。应用开发者在项目中获取 Project API key,设置 OPENAI_BASE_URL 与 OPENAI_API_KEY。Core key 不能用来调用 /v1,应用 key 也不能调用管理或机器接口。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/bootstrap-projects-keys)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 38b3434ee..853de0034 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -10,18 +10,14 @@ upstream resource shapes or execution semantics. ## Three namespaces, three credentials -Core serves three namespaces, each with one kind of caller and credential: +Applications use `/v1` with a Project API key. Administrators use `/core/v1` +with the Core key. Nodes and Runtime daemons use `/api/v1` with their own scoped +machine credentials. The [API index](/public-api) owns the complete caller, +credential and route matrix. -- `/v1`: applications with a Project API key. Exactly the pinned official routes; - Core-only fields live only in `x_agents_core`. -- `/core/v1`: Core Web's server and operator scripts with the Core key. Everything - about operating the deployment, including credential issuance. -- `/api/v1`: nodes, Runtime daemons and self-hosted executors with machine - credentials issued through `/core/v1`; each works only on its own routes. - -Core enforces the separation: the Core key cannot call `/v1`, API keys cannot call -`/core/v1`, and neither works on `/api/v1`. Web calls only `/core/v1` and keeps -the Core key on its server; users sign in to Web with it. +Web calls the management API through its server and keeps the Core key there. +Signing in grants administrator access to that deployment; it does not create +an application Session or give the browser a Project API key. This is a separation of API authority, not a claim that a deployment administrator cannot possess application credentials. An administrator can issue an API key and @@ -43,10 +39,9 @@ that key and preserves assets, provenance and accepted execution. Explicit Proje archive revokes every key and blocks new issuance in that Project. Administrators can still inspect and delete resources in archived Projects. -Store only API-key digests and necessary metadata. Return plaintext once, on -issuance, and never replay it after an uncertain response. Project/key mutations -and administrator audit commit together. Database authentication failures fail -closed. The Core key is managed separately, in deployment configuration. +The Core key is managed separately, in deployment configuration. Key storage, +authentication and audit guarantees are defined by the +[administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md). Parsar product identities, workspaces, business permissions and collaboration remain outside Core. Parsar is an ordinary API-key holder in a Project. @@ -66,6 +61,22 @@ create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. +## Runtime and outer isolation + +The same daemon and Core protocol serve self-hosted Linux, macOS and Windows. +OS differences belong to Runtime implementations; harness differences belong to +adapters. Managed Providers remain Linux-only. Runtime prepares capabilities and +executes work; Providers create, bootstrap and reclaim outer Environments. + +The daemon runs tools with its launching user's permissions. It does not add a +filesystem, permission or network sandbox, including on Linux. Outer Docker/E2B/ +microsandbox Environments provide managed isolation. Credential authentication, +ordinary private storage, atomic writes, locks and process cleanup remain required, +but do not protect Runtime data from tools running as the same user. Local +capability snapshots share the managed parser and do not create a second security +boundary. See the [native Runtime guide](/self-hosted-native) for current platform +validation limits. + ## Secrets and evidence Credential values, model credentials and confidential template initialization are @@ -82,13 +93,13 @@ copy operation keep their `admin_copy` ownership, distinct from historical unkno ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old -private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md). +private-protocol compatibility to this management model. Developers should follow the [contributor rules](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md) and +[Core–Runtime protocol](/runtime-protocol) when implementing these boundaries. Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/design-principles.md) diff --git a/apps/docs/content/docs/concepts.zh.mdx b/apps/docs/content/docs/concepts.zh.mdx deleted file mode 100644 index c0fc452ec..000000000 --- a/apps/docs/content/docs/concepts.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Concepts and ownership · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -Project 拥有应用资源和执行身份。同一项目的 API keys 共享资源;Core key 只用于管理。Core 负责执行事实,Web 不复制执行所有权。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/concepts)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 0ec141481..1473be48e 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -8,7 +8,7 @@ Every setting of a Core installation has exactly one home. There are two kinds: | Kind | Examples | Home | Change it with | Takes effect | | --- | --- | --- | --- | --- | | [Process settings](#process-settings-configjson) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, database pool, Runtime history export | `config.json` in the installation directory (default `~/.oac/core`) | Edit the file, then run `oac apply` | `oac apply` restarts the services that read the changed settings | -| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | At once, without a restart | +| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; node Runtime changes prepare asynchronously | Web's **System** page shows both: the installation's addresses, the process settings read-only under **Startup settings** with the path of `config.json` and the apply @@ -84,7 +84,7 @@ output or in Core's settings snapshot. Model providers are not process settings; | Key | Type | Default | Modes | Change | Restarts | Meaning | | --- | --- | --- | --- | --- | --- | --- | | `$schema` | string | none | all | any time | none | Editor hint that points at the installed copy of this schema. Ignored. | -| `format` | `1` | none | all | fixed | none | Configuration format. Only an upgrade changes it. | +| `format` | `1` | none | all | fixed | none | Configuration format for this release. Fixed after installation. | | `mode` | `"all"` \| `"core-only"` \| `"web-only"` | `"all"` | all | fixed | none | Which services this installation runs. Install flag: `--core-only` or `--web-only`. | | `native_core` | boolean | `false` | `all`, `core-only` | fixed | none | Run Core as a systemd user service instead of a container. Install flag: `--native-core`. | | `public_url` | string or null (canonical origin; HTTP only on loopback) | `null` | all | `oac apply` | core, web | Public origin of Core and Web behind your TLS reverse proxy, such as https://core.example. Nodes, sandboxes and self-hosted executors use it. null means local access only through http://127.0.0.1. Install flag: `--public-url`. | @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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`. @@ -128,9 +128,9 @@ Core API with the Core key. | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | -| Sandbox backend: Docker, microsandbox or E2B | **Nodes** (**Sandbox backend** with E2B): the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | -| Sandbox size and Runtime release | **Nodes**: the setup wizard | `/core/v1/sandbox/deployment` | Every sandbox gets the same size. See [Sandbox deployment](#sandbox-deployment) | -| E2B API key and template build | **Nodes**: the setup wizard's **E2B cloud** (the page is then called **Sandbox backend**) | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | +| Sandbox backend: Docker, microsandbox or E2B | **System** → **Sandbox backend**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | +| Sandbox size and Runtime release | **System** → **Sandbox backend**: **Change resources** | `/core/v1/sandbox/deployment` | New targets use the saved size; retained generations keep their original specification. See [Sandbox deployment](#sandbox-deployment) | +| E2B API key and template build | **System** → **Sandbox backend**: the setup wizard's **E2B cloud** | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**, **Edit node**, **Remove node** on a node's page (**Remove** in its list row) | `/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** | `/core/v1/harnesses/{harness}/model-provider` | See [Default models](#default-models) | @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -170,13 +170,21 @@ old key valid until the update succeeds. Initial setup requires a team-owned tem a legacy public-template configuration or an already revoked old key requires reset when Core cannot verify the committed ownership anchor. -Docker and microsandbox size/Runtime edits still require zero held resources and -retire the old nodes and enrollment commands. Add those hosts again after cleaning -up their retired node installations. Node online preparation and the corresponding -Web controls are separate follow-up work. History stays, and existing Sessions never -move between providers. See the -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and the [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md). +Docker and microsandbox size/Runtime edits advance the target generation online. +They require neither zero held resources nor node retirement or reenrollment. +Version 2 nodes prepare the target independently, while existing allocations and +suspended VMs retain their original generation. A qualified older serving generation +can still accept new Sessions when it has capacity, including while the target is +preparing or has failed. Target rollout and serving readiness are separate facts. +Use **System** → **Sandbox backend** → **Change resources** to edit the target; +**Nodes** owns node management and readiness. + +Current Runtime generation coexistence and ownership-scoped garbage collection +remain supported. They do not upgrade the installed node program or convert an old +installation. History stays, and existing Sessions never move between providers. +See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -203,10 +211,10 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see -[Upgrade notes](/troubleshooting#upgrade-notes). +[installation version policy](/troubleshooting#installation-version-policy). ## Secrets and identity @@ -223,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. @@ -238,15 +246,15 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | A Web you run without the installer reads the variables in [Connecting the administrator console to Core](/bootstrap-projects-keys#server-configuration-and-login), 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 and the -self-hosted install command are unavailable. +node payload (the installer's `node-payload/`). Without it, Add node is +unavailable. Native self-hosted installation uses its separate platform bundle. Core fails at startup, naming the replacement, while a retired variable is set: `AGENTS_API_DAEMON_WS_URL` (use `OAC_PUBLIC_URL`), `AGENTS_API_CONFIG_FILE` (no @@ -260,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/configuration.md) diff --git a/apps/docs/content/docs/configure.zh.mdx b/apps/docs/content/docs/configure.zh.mdx deleted file mode 100644 index 8f0c525b7..000000000 --- a/apps/docs/content/docs/configure.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Configuration reference · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/configure)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 6af130720..9346be90c 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/README.md) diff --git a/apps/docs/content/docs/console.zh.mdx b/apps/docs/content/docs/console.zh.mdx deleted file mode 100644 index 3f91dbe76..000000000 --- a/apps/docs/content/docs/console.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Administrator console · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -Web 是管理员控制台,提供 Monitor、Resources 和 Platform 操作。浏览器使用管理会话;Core key 作为上游凭据保留在 Web 服务端。应用 API 的执行请求不经过 Web。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/console)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/development.mdx b/apps/docs/content/docs/development.mdx new file mode 100644 index 000000000..4382aa2ce --- /dev/null +++ b/apps/docs/content/docs/development.mdx @@ -0,0 +1,214 @@ +--- +title: "Develop OpenAgentCore" +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md) before changing code. + +## Set up a checkout + +Work from an isolated worktree so experiments and validation do not disturb +another checkout. From an existing clone with an up-to-date `main`: + +```sh +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/go.mod), Node 22, pnpm at the version +in [package.json](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. + +```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 - <<'PYTHON' +import json +import subprocess +import sys + +pin = json.load(open("contracts/agents-api/upstream.json")) +subprocess.check_call([ + sys.executable, "-m", "pip", "install", + "git+" + pin["repository"] + "@" + pin["commit"], +]) +PYTHON +pnpm exec playwright install --with-deps chrome +export OAC_TEST_OFFICIAL_SDK_PYTHON="$PWD/.venv/bin/python" +``` + +Set `OAC_TEST_DATABASE_URL` privately to a dedicated `oac_*_tests` PostgreSQL +database. Its role needs permission to create databases: managed-provider tests +create and drop isolated test databases. Never point the test suite at an +installation or product database. The full gate fails if this setting is missing. + +Third-party native packages are pinned build inputs; changing a pin requires the +relevant native qualification as well as compilation. + +## Build and run components + +From the repository root: + +```sh +make build-agents-api +make build-daemon +``` + +These produce the Core commands under `~/.oac/build/oac-core/` and the daemon +under `~/.oac/build/daemon/` by default. `OAC_DEV_HOME` selects another build root; +`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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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) | +| `internal/agentdaemon/proto` and `gateway` | Shared wire types, validators and authenticated Runtime connections | [Runtime protocol](/runtime-protocol) | +| `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#minimal-runnable-example-and-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 | [Web architecture](/execution-model) | +| `apps/web` and `packages/agents-client` | Console UI and typed clients | [Web guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/web/README.md) | +| `deploy/install` and `scripts` | Distribution, installation and validation tools | [Maintainers](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/maintainers.md) | +| `contracts/agents-api` | Pinned schema, local semantic contracts and qualification evidence | [Coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md) | +| `apps/docs` | Generated guide and API-reference website | [Generation workflow](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 +apply to user-owned machines and Core-managed environments; read the +[design principles](/concepts) for resource and credential vocabulary. + +## Choose an extension boundary + +### Add a Harness adapter + +Start with [Harness onboarding](/harness-onboarding). +Choose a native SDK or machine-readable protocol, declare the operations and +placements you intend to support, and implement the shared +[`Executor` and `Turn` interfaces](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/agent/executor.go). +The guide covers factory registration, service profiles, capability declarations, +input receipts, event translation, cancellation, history and cleanup. + +Use the [synthetic onboarding fixture](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/testdata/onboarding/main.go) +to see the full registration path. With the dedicated test database configured: + +```sh +go test ./services/agents-api/internal/store -run '^TestThirdHarnessPublicOnboarding$' -count=1 +``` + +This test reaches the actual gateway and Runtime router, but its synthetic engine +cannot establish native acceptance. Qualify the real adapter through the official +client, Core, Runtime and a real model. Verify two ordinary Turns, cancellation, +continuation after restart, unknown outcomes and the advertised optional operations. +Record exact native versions and limitations in the linked operation contracts. + +The [Claude bridge guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/packages/claude-sdk-adapter/README.md) explains the +separate TypeScript SDK process. Codex and MiniMax implementation references are +linked from the onboarding guide. Reuse common process and workspace helpers; +keep native event interpretation inside the adapter. + +### Extend the Core–Runtime protocol + +Read [the protocol guide](/runtime-protocol) before adding a message or receipt. +Change the shared types and validators in `internal/agentdaemon/proto`, both peers, +and their contract tests together. Keep exact wire-version checks and update the +message ordering, identity and failure ownership documentation. A transport write +cannot be used as evidence of native application or completion. + +```sh +make check-runtime-contract +``` + +The focused gate covers wire validation, gateway behavior, transport, dispatcher, +real WebSocket contract fixtures and Core observation failures. Adapter-specific +native acceptance remains a separate requirement. + +### Add a Sandbox Provider + +Read [Provider onboarding](/sandbox-provider). Implement +[`SandboxProvider`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/sandbox/sandbox_provider.go) +for the outer compute lifecycle. Register construction and vendor configuration at +that boundary; use optional interfaces for observation and checkpoints. Runtime +capability preparation and native execution keep their existing owners. + +```sh +make check-sandbox-provider-contract +``` + +Run the [shared contract suite](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/sandbox/contracttest/provider.go) +through the adapter's native boundary, then qualify actual resource creation, +bootstrap, observation, uncertain results and cleanup. Mocked compute cannot prove +that resources were reclaimed or that the outer environment provides isolation. + +## Validate a change + +Run checks for the affected boundary while developing. The repository +[required checks](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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` | +| 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` | +| Provider integration | `make check-sandbox-provider-contract` and the provider's native checks | +| Claude SDK bridge and artifact | `make check-claude-sdk` | +| Web UI and clients | `make check-web` | +| Distribution or installer | `make check-distribution` | +| Docs sources and generated site | `pnpm --dir apps/docs generate`, then `make check-docs` | + +When the complete change is ready: + +```sh +make check +``` + +Fixture browser acceptance uses loopback ports 18092 and 4174. Select unused ports +with `AGENTS_FIXTURE_PORT` and `AGENTS_WEB_PORT` when running parallel validation. +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#workflow-and-quality) +after validation. + +## Change documentation + +Edit the source that owns the subject, using the +[documentation ownership map](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. + +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/development.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 566f1d3c9..9a790f56e 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,16 +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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md). +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. @@ -30,27 +33,37 @@ 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same -Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted -policies use the same qualified image with adapter-selected immutable native policy. +Omitted/null network defaults to enabled. The daemon does not enforce disabled +or restricted networking. An execution combination must reject unless its outer +Environment implements and qualifies the requested behavior. Templates and inline configuration share initial files, env, packages, ordered setup -and inline or tenant-owned referenced Skills through the hosted initializer. +and inline or tenant-owned referenced Skills through ordered managed initialization. +The same authenticated daemon handles initial files, tool configuration, packages, +setup, Skill/Plugin import and directory snapshot finalization through +`runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim +compute. npm/Python installation and setup use the host's existing network and +starting account. System dependencies must be preinstalled; `packages.system` +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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. @@ -70,18 +83,19 @@ 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. ## Initial public self-hosted profile Create a Session with `environment.type=self_hosted`, -`workspace_directory: "/workspace"` and omitted/null/empty `capability_directories`. Creation +a clean absolute `workspace_directory` and optional absolute local +`capability_directories`. `/workspace` maps to the Runtime-bound workspace. Creation accepts initial text as a string or ordered user-message array. Omitted/null input creates no Turn or connection action. Configured execution, an enabled harness and the exact local profile are validated @@ -111,7 +125,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -125,9 +139,15 @@ existing function parser and remain fixed through native preparation and continu output/error field presence and ordered content keep their existing semantics. Retries retain the original call, including during later work. New results cannot bypass pending input. Function callbacks do not populate Environment installations. -Mixed events, non-text input and nonempty capability directories remain unsupported. -The public field types are unchanged; `/workspace` is the V1 deployment limit, not -an upstream schema change. `remote_url` is the configured daemon WebSocket URL, +Mixed events and non-text input remain unsupported. Local capability directories +use the same Runtime parser and installed snapshot as managed Skills and Plugins; +preparation must finish before native execution. Reconnect reuses installed bytes, +while new Sessions capture their own sources. Directory aliases and protected Runtime +state are rejected. The current implementation uses the packaged Linux Runtime +layout; cross-platform installation and isolation are not part of this profile. +The public field types are unchanged. Enrollment must match either the `/workspace` +logical alias or the exact canonical directory bound by the Runtime; selecting an +arbitrary path does not grant access. `remote_url` is the configured daemon WebSocket URL, returned unchanged. This is our private connection contract and does not claim stock `exec-server` compatibility. Service-origin HTTP MCP is explicitly rejected on `self_hosted`; `none` MCP and hosted Template Plugin MCP retain their own scope. @@ -138,6 +158,50 @@ exercise fixed SDK/raw HTTP creation/input, actual native tools, Files/Artifacts second-Turn history, Core/Runtime restart, cancellation and credential rotation/ revocation/deletion on each declared deployment. +## Runtime capability preparation + +Core freezes resource versions, metadata and source selections. Managed initialization +keeps initial files and tool configuration before Runtime Skill/Plugin import, +then runs packages and ordered setup before Runtime snapshots local capability +directories. All steps use the common `runtime_prepare` protocol; Providers do +not execute Core initialization commands. The common runner depends on neutral +Environment/Session identity and a Runtime peer, not Provider, deployment or OS. Self-hosted input +accepts only local `capability_directories` with `workspace_directory`, not managed +Skill/Plugin archive fields or a hosted Template. Local directory discovery does not +populate the public API-managed `skills` or `plugins` installation arrays. + +Both origins use the common Runtime parser and `installed.json` on Linux, macOS +and Windows. The operator selects the capability root; the native installer +uses `capabilities` under `OAC_RUNTIME_HOME` by default. Core and public requests +cannot select that destination. Read-only snapshot modes are integrity hints, +not protection from the launching user. The manifest binds one +Session and Environment to the source-selection digest. The admitted asynchronous +preparation owner verifies or creates it before native execution, including for an +empty selection. Reconnect and replacement executors reuse installed contents; +source edits are observed only by a new Session with its own Environment and fresh +snapshot. A private completion record retains only the operator installation root, +so loss of a completed snapshot cannot trigger source recapture. Missing, partial or +conflicting snapshots fail without automatic repair or replay. Adapters receive only the resulting Runtime-owned Skill paths and MCP +declarations. Files access retains its separate readiness and authorization. + +Resource management owns allocation and Environment creation, renewal and reclamation. +Executor close, Turn cancellation and transport loss preserve the installed snapshot, +workspace and allocation. Reclamation is an explicit operation coordinated with active +work; a disconnected socket is not proof that native effects have stopped. + +Source selections accept portable absolute Unix, Windows drive and UNC paths; +Core does not resolve these paths on its own host. The daemon applies its local +path and access checks. Initial files and setup working directories use logical +`/workspace` paths. Harness differences remain in adapters. + +The protocol and Go preparation implementation are shared by all three native +platforms. Resource directories are operator settings, not deployment branches. +Managed Providers remain Linux-only. The daemon does not sandbox tools, files or +network access; tools use the starting account's permissions. Native installation +and current validation limits are in the [native guide](/self-hosted-native). +Historical deployment acceptance does not qualify later platform or capability changes. See the +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#shared-runtime-capability-preparation). + ## Contract inventory Paths below follow the SDK resource methods, before the service's `/v1` prefix. @@ -191,7 +255,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -215,4 +279,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/environments-and-files.zh.mdx b/apps/docs/content/docs/environments-and-files.zh.mdx deleted file mode 100644 index 26bfc7865..000000000 --- a/apps/docs/content/docs/environments-and-files.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Environments and files · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -Environment 与 Session 一起创建。none、self_hosted、openai_hosted 是协议中的执行位置名称;openai_hosted 在这里表示 Core 管理的计算资源。文件和工作区仍由对应资源及执行所有者管理。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/environments-and-files)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 1c57f5eee..76f2c83d2 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/web/architecture.md) diff --git a/apps/docs/content/docs/execution-model.zh.mdx b/apps/docs/content/docs/execution-model.zh.mdx deleted file mode 100644 index d331de11c..000000000 --- a/apps/docs/content/docs/execution-model.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Execution and API architecture · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -应用直接用 Project API key 调用 Core 的 /v1。管理员浏览器通过 Web 会话操作管理界面,Web 服务端持有 Core key。节点和 Runtime 用专属凭据直接连接 Core。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/execution-model)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/harness-onboarding.mdx b/apps/docs/content/docs/harness-onboarding.mdx new file mode 100644 index 000000000..cefe8073e --- /dev/null +++ b/apps/docs/content/docs/harness-onboarding.mdx @@ -0,0 +1,264 @@ +--- +title: "Add a Harness" +description: "Implement and validate a native Harness adapter." +--- + +Implement one adapter for a native SDK or machine-readable protocol. Core and +Runtime use the common contract; adding a harness should not add engine-name +branches to public handlers, storage, scheduling or environment providers. + +The [harness contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/harnesses.md) defines observable behavior. Start with the +[developer guide](/development) for a working checkout; the +[contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#harness-qualification-and-onboarding) +owns architecture and required checks. The pinned public Agents API in +[upstream.json](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/upstream.json) is separate from the internal adapter protocol. + +## Ownership + +```text +Core: Session, Turn, immutable configuration, durable events + | + common Runtime protocol + | +Runtime: Executor preparation, reuse, idle expiry, recovery + | + Harness adapter package + | + native SDK, process or connection +``` + +| 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/` | +| 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` | + +An Environment supplies execution resources. Managed Docker, E2B and user-managed +machines differ in provisioning and connection; their connected Runtime uses this +same contract. Operating-system support belongs in the implementation and its +qualification. The native daemon supports Linux, macOS and Windows; each adapter +declares its qualified platform scope. Managed Providers remain Linux-only. +A platform-neutral interface alone does not qualify a harness on another platform. +See [native Runtime validation](/self-hosted-native) for the current +acceptance limits. Resource management owns machine selection, allocation and +Environment creation and reclamation. Executor close does not release that allocation or delete the +workspace. Environment reclamation is an explicit resource-management action that +coordinates with execution. Runtime connection, installed capability snapshot, +Session Executor and Turn each have their own lifetime. Native factories receive +capabilities only after the common Runtime has loaded its bound installed snapshot; +see [capability preparation](/environments-and-files#runtime-capability-preparation). +Model providers supply +model communication configuration, not Turn scheduling or native process ownership. + +## Required adapter interfaces + +The canonical signatures and ownership comments are in +[`agent/executor.go`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/agent/executor.go): + +```go +type ExecutorFactory func(context.Context, proto.PromptRequestPayload) (Executor, error) + +type Executor interface { + StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Turn, error) + Close(context.Context) error +} + +type Turn interface { + Cancel(context.Context) error + CancellationOutcome() proto.DonePayload + AwaitSettlement(context.Context) (TurnSettlement, error) +} + +type TurnSettlement struct { + Reusable bool + Reason string +} +``` + +The factory prepares a fixed configuration without sending model input. Executor +owns the native process or connection, native Session, capability configuration and +cleanup resources. If preparation fails while cleanup remains unconfirmed, return +that non-nil Executor together with the error so Runtime retains its cleanup owner. +`StartTurn` creates a fresh Turn wrapper and event stream; +normal completion retains the Executor. Never reset a completed Turn object or +restart the process merely to implement the next Turn. + +A nil Turn from `StartTurn` guarantees no input was submitted and no output writer +was retained. Runtime closes the output channel in that case. Once input may have +been submitted, return a non-nil Turn even with an error: that Turn owns exactly +one output-channel close, and uncertain input must not be replayed. + +`Cancel` addresses that exact Turn. `AwaitSettlement` confirms that native events, +inputs, function results, interactions, child work and output writes have settled. +Only then may `Reusable` be true. False requires a reason and confirmed Executor +close before replacement. An error means settlement is unconfirmed. A caller +cancelling its wait cannot discard resource ownership or redirect a late callback +to the next Turn. Close must retain its exact cleanup target after failure so the +Runtime can retry serially. Successful resource close alone does not prove that a +cancelled input was applied or that its original outcome is known. + +The Runtime owns admission handles, idle deadlines and resource limits. Adapters +own native translation. For example, a Codex adapter retains its app-server and +thread, a Claude adapter retains one streaming Query, and a MiniMax adapter retains +its ACP connection and native Session. Core sees the same Executor and Turn +semantics in each case. + +## Events, inputs and optional capabilities + +Use [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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; +never attribute a late result to whichever Turn is currently active. + +Initial input and steering use ordered `proto.MessageInput`. Preserve user-message +and content order. Text-only adapters reject images through `TextOnly()` instead +of dropping them. A successful transport write is distinct from confirmed native +application. Resume only the exact history bound to the Session; missing or +ambiguous required history fails before new model input. + +| Interface or contract | When required | Obligation | +| --- | --- | --- | +| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity | +| `agent.FunctionResultSubmitter` | Public function tools | Match call/result identity and acknowledge native application | +| `agent.PermissionResponder`, `agent.UserChoiceResponder` | When emitting these interactions | Route exact identities and settle receipts | +| `agent.WorkspaceReader`, `agent.WorkspaceDirectoryLister` | Qualified workspace operations | Use the fixed authorized workspace and retain accepted operations through close | +| Neutral message, image, MCP, structured-output and Subagent observations | Only when qualified and advertised | Preserve the operation-specific contract and reject unsupported combinations | + +Optional features need not match another harness. The service profile qualifies +public combinations; the Runtime advertises this installation's available support. +Neither replaces schema validation or tenant authorization. Declaring a capability +without implementing its semantics is an error. Workspace reads may use separate +read-only preparations; those do not start model work or provide another execution +lifecycle. + +## Register a supported operation set + +1. Pin the upstream source/package version and document the native entry point. +2. Implement the adapter using its SDK or native protocol. Reuse shared process, + credential/configuration and local workspace helpers where applicable. +3. Register its `SupportedAgentKind` and direct-call factory with `RegisterKind`, + then register the shared `ExecutorFactory` with `RegisterExecutor`. A direct-call + factory should delegate to the same Executor implementation. Register separate + read-only preparations only when required by qualified workspace operations. Runtime + advertisements must describe behavior verified for that installation. +4. Add a profile to the existing static catalog, or supply an immutable catalog + through service composition. Custom composition supplies the same + `execution.Policy` to `api.WithExecutionPolicy` and `Dispatcher.Policy`. +5. Package the native prerequisites and select the engine through operator + configuration (`OAC_DEFAULT_HARNESS`). Do not invent a public harness field. + +The static registration surface requires a build. Dynamic plugins are outside +this contract. A small adapter does not remove the need for native qualification. +The runnable test-only example is +[`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/testdata/onboarding/main.go). +It registers a text-only synthetic harness and is never shipped as a real engine. + +## Required behavior versus optional operations + +The current public text path requires durable turns, applied input receipts, +ordered observations, cancellation and enforcement of disabled execution controls. +Check `execution.Policy.engineCapabilities` for the exact current requirements. +An engine without native tools can guarantee their absence; an engine with tools +must actually disable them when requested. Configuration acceptance is not proof +of enforcement. + +MCP, public function calls, deferred function discovery, structured output, image inputs, verbosity controls and other optional +operations do not need to match another engine. Reject unqualified combinations +explicitly and record the gap. Never advertise a capability to bypass selection. + +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 +`MessageImages` and qualify `MessageImagePlacements` separately; see the +[message-input contract and real acceptance](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 +same dedicated Runtime binding and shared Files helpers. A native Bash sandbox +alone does not establish isolation for other native file tools. Enable a placement +only after its required security and lifecycle behavior is demonstrated. + +## Acceptance and delivery + +Before implementation, record the operation set, expected results, exclusions and +stopping conditions in the board. A batch ends when its declared operations pass; +it does not expand to match another harness's feature list. + +- Adapter tests: two ordinary Turns share one native process/connection and history; + cancellation followed by another Turn; stale cancellation and late events; native + exit, cleanup failure, input write/application receipts and unknown outcomes. + Verify fresh per-Turn usage, function, input and child-observation state. +- Shared integration: public admission, actual Worker/device selection, gateway, + daemon registration/dispatch and durable terminal projection. See + `TestThirdHarnessPublicOnboarding` for a synthetic example, not native evidence. +- Real acceptance: pinned official Python SDK and raw HTTP against our Agents API, + real provider API, native harness and independent execution database. Verify + initial execution, warm follow-up, cancellation and restart/continuation. + Record native owner identity and same-condition cold/warm timing; mock results + cannot establish native reuse or performance gains. + For hosted qualification also verify Files/Artifacts, workspace identity, + credential protection and foreign-history rejection. +- Regression: existing qualified engines keep working. Run targeted tests during + development, then `make check` and applicable real regressions. API changes + require `make openapi`; query changes require `make sqlc-generate`. +- Review: follow the repository + [blind review workflow](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md#workflow-and-quality). + +Record exact revisions, image/package versions, commands, results and limits. +Keep keys in private operator files; never commit them or include them in logs or +Feishu. Failed or synthetic runs cannot be counted as real acceptance. Merge the +bounded PR after required checks/review, update its board child and reassess the +full board. Acceptance of one engine is not complete public protocol compatibility. + +## Minimal runnable example and native references + +[`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/testdata/onboarding/main.go) +is a runnable synthetic adapter. It demonstrates registration, a Session-owned +Executor, fresh Turns, durable steering, cancellation and history binding through +the actual gateway and Router. Run `TestThirdHarnessPublicOnboarding` against a +dedicated test database to exercise the complete integration path. The fixture is +not shipped and is not evidence for a real SDK or sandbox. + +Use the native adapters as implementation references after choosing a native API: + +- [Codex](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/agent/codex/executor.go): app-server transport. +- [Claude](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/agent/claudesdk/executor.go): Go ownership and a [TypeScript SDK bridge](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/packages/claude-sdk-adapter/README.md). +- [MiniMax](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/agent/mcode): ACP and native workspace companion. + +Start with the mandatory text lifecycle, then qualify optional operations using +[harness coverage](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/harnesses.md) and each operation's contract. Do not copy an +adapter's native limitations into the shared Core protocol. + +## 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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 +unsupported native facts explicitly; completing a child task is not closing its +Subagent. Native background work must remain owned through settlement and cancel. + +## Native installer participation + +An adapter may supply `agent.Installation` from `installation.go` in its own +package: registered agent kind, pinned version, supported platforms, activation +environment and a bounded +readiness probe. Register it in `cli/native_harness.go` and add its pinned component +to the native distribution builder. This optional contract does not change +Executor/Turn semantics. Runtime owns checksums, copying, locks and additive +installation; adapters own native layout and probes. Validate installation and +actual execution on each advertised platform. Missing or incompatible native +content must fail, never install itself during a Turn. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 ff42029e8..6f747baaa 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,40 +290,17 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. -## Remove a node added before the rename - -A new installer refuses retained state for the same installation under the old -`parsar-node` names. It does not convert that state or remove nodes belonging to -another installation. Remove the node on its old Core's Nodes page, then run the -**previous release's** verified `node-install.pyz --uninstall --installation-id -<installation-id>`, with `sudo` only if that node was installed with sudo. Use -`--force` only when that Core no longer exists. - -If the previous installer is unavailable, inspect and remove only that -installation's resources: - -1. Stop and disable `parsar-node-.service` with `systemctl`, using - `sudo` for a system service or `--user` as the original installing user. -2. Remove its exact unit file or enablement link. System units live in - `/etc/systemd/system/`; user links live in `~/.config/systemd/user/`. Reload the - same systemd manager after removing the unit. -3. After stopping the node, remove its state directory as its owner: - `/var/lib/parsar-node/.parsar/nodes/` as `parsar-node` in sudo - mode, or `~/.parsar/nodes/` as the original user. Do not run - root cleanup through files or links controlled by the service account. -4. In sudo mode, remove only `/etc/parsar-node/.json`. Keep the - account record, the `parsar-node` user and group memberships while any other - installation or retained microsandbox store uses them. -5. For Docker, remove `parsar-node-` only after confirming that - its network has no attached containers. Keep Runtime images, volumes and - retained microsandbox stores until their owners have explicitly retired them. - -Then use the new Core's Add node command. The new account, service and state use -`oac-node` and `~/.oac/nodes`; historical Core Session records remain in Core. - -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/nodes.md) +## Historical node installations + +An older node installation is unsupported by the current installer. Retained state +under the old `parsar-node` names is a refusal condition, not an invitation to +convert or delete it. Preserve the old installation, Runtime resources and history; +provision a fresh node separately through **Add node**. The current installer does +not adopt historical resources or remove another installation's state. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/hosted-providers.zh.mdx b/apps/docs/content/docs/hosted-providers.zh.mdx deleted file mode 100644 index 81dbd00dd..000000000 --- a/apps/docs/content/docs/hosted-providers.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Nodes and hosted providers · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -在 Web 的 Nodes 页面添加节点,并在目标主机执行经过校验的命令。sudo 模式使用 oac-node 系统账户;无 sudo 模式使用用户服务。节点程序不支持通过 --update 升级版本;保留旧节点状态和 Runtime 数据,另行添加新节点。当前 Runtime generation 操作不属于节点程序版本升级。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/hosted-providers)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 9311b1bfb..f391ecb88 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -3,26 +3,42 @@ title: "OpenAgentCore documentation" description: "Install Core and Web, create a Project API key, and add execution capacity." --- -OpenAgentCore is self-hosted, open-source Agents API infrastructure: Core serves the -OpenAI Agents API and runs native harnesses (Codex, Claude Code and MiniMax Code) in -sandboxes on your machines. Web is the administrator console; it issues the keys that -applications call Core with. - -| Page | For | What it covers | -| --- | --- | --- | -| [Install Core and Web](/install) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | -| [Nodes](/hosted-providers) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | -| [Self-hosted executors](/self-hosted-execution) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | -| [Operations](/troubleshooting) | Administrators | The `oac` command, the Core key, backups, upgrades and troubleshooting | -| [Configuration reference](/configure) | Administrators | Every `config.json` setting and every runtime setting in Web | -| [Call the API](/quickstart) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | -| [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | - -Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/maintainers.md) for building distributions -and running Core without the installer. - -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/README.md) +Start with the path that matches your role. These pages are the canonical sources +for the documentation website; generated site pages are not a second manual. + +## First installation and first task + +1. [Install Core and Web](/install), then sign in with the Core key. +2. [Add managed execution capacity](/hosted-providers) or + [connect your own Runtime](/self-hosted-execution). +3. Issue a Project API key and follow the [first Session walkthrough](/quickstart). +4. Continue with the [user guide](/user-guide) for capabilities, files and recovery. + +## Operate a deployment + +| Guide | Use it for | +| --- | --- | +| [Installation](/install) | Host prerequisites, release bundles, HTTPS and first sign-in | +| [Configuration](/configure) | Installer configuration and database-owned settings | +| [Nodes](/hosted-providers) | Managed capacity, readiness and removal | +| [Self-hosted execution](/self-hosted-execution) | Connecting a user-owned machine to a Session | +| [Native daemon](/self-hosted-native) | Platform support and local installation lifecycle | +| [Operations](/troubleshooting) | Service lifecycle, backups, keys, repair and troubleshooting | +| [Administrator console](/console) | Monitoring and managing Core through Web | + +## Build an application + +The [user guide](/user-guide) connects common tasks to their detailed contracts. +The [API index](/public-api) identifies each caller and credential. Use the +[coverage record](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md) to check operation support +and Harness differences before relying on an optional feature. + +## Extend Core + +Start with the [developer guide](/development). It links the repository map, +required checks and extension points for Harness adapters, Runtime implementations +and Sandbox Providers. Architecture rules live in +[CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/CONTRIBUTING.md); distribution and release procedures live in +[the maintainer guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/maintainers.md). + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/index.zh.mdx b/apps/docs/content/docs/index.zh.mdx deleted file mode 100644 index f1cb5461f..000000000 --- a/apps/docs/content/docs/index.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "OpenAgentCore documentation · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -安装 Core 与 Web,用 Core key 登录 Web,创建项目并签发应用 API key,然后添加执行节点。应用使用 /v1;Web 的服务端使用 /core/v1;机器连接使用 /api/v1。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index 38e3ef03d..d1cefcebc 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -16,7 +16,8 @@ add nodes. Applications call Core's API with those keys. 6. [Add a node](/hosted-providers). These pages describe the current source. Every bundle carries the docs that match it -under `docs/`; if you install an older bundle, follow those. +under `docs/`. This private project supports fresh installation and repair of the +same release; historical-version upgrades and conversion are unsupported. ## Prerequisites @@ -139,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -163,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -489,7 +490,7 @@ at Add node. An existing pre-rename installation at `~/.parsar/core` blocks a new default install. Historical conversion is not supported. Preserve the existing installation and explicitly -choose another `--install-dir`. See [installation version policy](/troubleshooting#convert-an-earlier-installation). +choose another `--install-dir`. See [installation version policy](/troubleshooting#installation-version-policy). ## What the installer creates @@ -504,7 +505,7 @@ The installation directory, `~/.oac/core` by default, mode `0700`: | `secrets/database.password` | PostgreSQL password | | `state.json` | Format 2, installation ID, Compose project name, image IDs and source commit. Written by the tools only | | `generated/` | Files derived from `config.json`: `compose.json`, `core.env`, `core-key-digests.json`, `settings.json`, `config.schema.json`, and `runtime-history.json` or the native Core unit when used. `oac apply` rewrites them; don't edit them | -| `node-payload/` | The node and self-hosted installers, the manifest and the node files that Web serves at `/node-install/` | +| `node-payload/` | The managed node installer, the manifest and the node files that Web serves at `/node-install/` | | `state/e2b/` | Private E2B receipts | | `native/` | Core binaries, with `--native-core` only | @@ -528,6 +529,6 @@ Rerunning `./install.sh` from the same bundle repairs an installation: it reload missing images, restores a missing `oac` command, copies node files newly placed in `artifacts/`, applies `config.json` and starts the services. It accepts only `--install-dir`. It refuses a bundle from another release; see -[Upgrades](/troubleshooting#upgrade-an-installation). +[installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/install.zh.mdx b/apps/docs/content/docs/install.zh.mdx deleted file mode 100644 index adc460148..000000000 --- a/apps/docs/content/docs/install.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Install Core and Web · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -安装器默认在 ~/.oac/core 安装 Core、Web 和专用 PostgreSQL,初始没有执行节点。先确定公网 HTTPS 地址,按当前版本的校验步骤下载并安装,再用 Core key 登录 Web。 只支持全新安装与同一版本修复;不支持旧版本原地升级或历史转换。保留旧安装和数据,在新的空目录安装当前版本。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/install)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 73b131510..f2a515c65 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -10,6 +10,7 @@ "---Application access---", "bootstrap-projects-keys", "quickstart", + "user-guide", "public-api", "agents-and-tools", "sessions", @@ -17,11 +18,17 @@ "environments-and-files", "hosted-providers", "self-hosted-execution", + "self-hosted-native", "---Administration---", "console", "admin-api", "observability", "troubleshooting", - "api-reference" + "api-reference", + "---Development---", + "development", + "harness-onboarding", + "runtime-protocol", + "sandbox-provider" ] } diff --git a/apps/docs/content/docs/meta.zh.json b/apps/docs/content/docs/meta.zh.json deleted file mode 100644 index e1c42ae20..000000000 --- a/apps/docs/content/docs/meta.zh.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "title": "OpenAgentCore 文档", - "pages": [ - "---从这里开始---", - "index", - "concepts", - "execution-model", - "install", - "configure", - "---应用接入---", - "bootstrap-projects-keys", - "quickstart", - "public-api", - "agents-and-tools", - "sessions", - "---执行环境---", - "environments-and-files", - "hosted-providers", - "self-hosted-execution", - "---管理---", - "console", - "admin-api", - "observability", - "troubleshooting", - "api-reference" - ] -} diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 4ca748148..44b32d960 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/observability.zh.mdx b/apps/docs/content/docs/observability.zh.mdx deleted file mode 100644 index 981a98f6c..000000000 --- a/apps/docs/content/docs/observability.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Runtime observations · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -当前 Runtime 观测与已保存历史是不同的数据。离线或陈旧数据不代表当前健康状态;查看来源、时间和可用性,执行事实仍以 Core 为准。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/observability)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 4c9bea5e2..24be1fdc9 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | 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`) | [Public API](/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | 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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ 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 operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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 operations](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/api/README.md) diff --git a/apps/docs/content/docs/public-api.zh.mdx b/apps/docs/content/docs/public-api.zh.mdx deleted file mode 100644 index 7d2fc3a97..000000000 --- a/apps/docs/content/docs/public-api.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "API namespaces and credentials · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -/v1 使用 Project API key;/core/v1 使用 Core key,由 Web 服务端或管理员脚本调用;/api/v1 只接受对应的节点、注册、daemon 或 executor 凭据。这三类凭据不能互换。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/public-api)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index bd3558799..f4aaed387 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -3,6 +3,10 @@ title: "Call the Agent API" description: "Use the pinned SDK, a Project API key and an explicit model provider to run a Session." --- +Before starting, [install Core and Web](/install), issue a Project API key, and +prepare a ready managed node or E2B backend. You also need Python 3.9+ and model +provider access. This walkthrough uses Codex with a Responses-compatible provider. + Core serves the OpenAI Agents API. Applications use the official OpenAI SDK with two environment variables, which the SDK reads by default: @@ -49,6 +53,16 @@ Project and revoke the old one; your assets stay. ## Run a Session +Set the provider's exact model ID and Responses API base URL. Use a test account +that is authorized to make a model request: + +```sh +export MODEL_NAME='your-model-id' +export MODEL_BASE_URL='https://your-provider.example/v1' +read -rs MODEL_API_KEY && export MODEL_API_KEY +``` + + A Core-hosted Session (`openai_hosted`) runs in a sandbox that Core creates: on a node with free capacity, or on E2B. Core prepares the daemon, native harness and workspace inside it. The installation needs a ready node or an E2B backend; see @@ -101,8 +115,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -127,23 +141,39 @@ returns it. Self-hosted Sessions always bring their own provider, because the de holds the operator's key and the executor host belongs to the application; see [Self-hosted executors](/self-hosted-execution). -## Observe and recover +## Wait for the result + +Continue in the same Python process. A Session ID confirms creation, not success. +This bounded loop reads durable state instead of resubmitting work: ```python -current = client.beta.agents.sessions.retrieve(session.id) -turns = client.beta.agents.sessions.turns.list(session.id) -items = client.beta.agents.sessions.items.list(session.id) -print(current.id, turns.data, items.data) +import time + +for _ in range(120): + turns = client.beta.agents.sessions.turns.list(session.id, order="desc").data + if turns and turns[0].status in {"completed", "failed", "cancelled"}: + turn = turns[0] + print("Turn:", turn.id, turn.status) + print(client.beta.agents.sessions.items.list(session.id).data) + if turn.status != "completed": + raise RuntimeError("Inspect the recorded Turn and Items before retrying") + break + current = client.beta.agents.sessions.retrieve(session.id) + if current.status == "failed": + raise RuntimeError("Session preparation failed; inspect its Environment") + time.sleep(1) +else: + raise TimeoutError(f"Session {session.id} is still pending; inspect it before retrying") ``` -Wait for the Turn's final state before treating a task as done. After a disconnect, -recover through Session, Turn and Items reads: the event stream is live only and does -not replay history. Don't submit the same work as a new Session when a response is -lost. +Success means the Turn is `completed` and its recorded output describes the created +file. A timeout does not cancel the task or prove failure. The +[user guide](/user-guide) explains how to inspect files, send another message, +add Skills/Plugins/MCP, cancel work and recover after disconnection. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/quickstart.zh.mdx b/apps/docs/content/docs/quickstart.zh.mdx deleted file mode 100644 index e652c1784..000000000 --- a/apps/docs/content/docs/quickstart.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Call the Agent API · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -应用设置 OPENAI_BASE_URL=https://core.example.com/v1 和 OPENAI_API_KEY=<project-api-key>,使用文档固定的 SDK 版本。先配置执行后端和模型,再创建 Session 并读取 Turn 结果。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/quickstart)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/runtime-protocol.mdx b/apps/docs/content/docs/runtime-protocol.mdx new file mode 100644 index 000000000..3649bdf69 --- /dev/null +++ b/apps/docs/content/docs/runtime-protocol.mdx @@ -0,0 +1,231 @@ +--- +title: "Core–Runtime protocol" +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto); +Core's [gateway](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/gateway) and the reference Runtime's +[dispatcher](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/apps/parsar-daemon/internal/dispatch) both use them. +The [machine HTTP API](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. + +Use this protocol for hosted and self-hosted Runtime implementations. A new +Harness implements the [adapter contract](/harness-onboarding) +behind the Runtime registry. Do not add a Core orchestration branch named after +the Harness, operating system or Sandbox Provider. + +## Ownership and connection + +Core owns durable Session, Turn, input and Environment records, scheduling and +reconciliation. Runtime owns native Executors, active Turns, transfer state and +cleanup until settlement. A Sandbox Provider owns placement and the surrounding +compute lifecycle. Releasing an execution admission or closing an Executor does +not delete, suspend or reclaim a sandbox. + +An installed daemon runs with the authority of the user who starts it. The +protocol does not make the daemon a tool, filesystem or network isolation +boundary. Core-managed Docker, E2B or other sandboxes provide the outer isolation. +A logical workspace, native tool configuration, advertised capability or successful +preparation is not proof of containment. + +1. Obtain the appropriate machine credential using the documented registration + flow. Applications use Project API keys; operators use Core keys. Neither is + a Runtime connection credential. +2. Bootstrap through `/api/v1/agent-daemon/bootstrap` with an Authorization Bearer + header and device identity. Use its authenticated connection URL. +3. Dial the reverse WebSocket at `/api/v1/agent-daemon/ws`, supplying `device_id` + and `version` query parameters and the Bearer header. Never put credentials + in a URL, payload log or trace. +4. Send an immediate heartbeat, then continue at the configured interval. + Declare `supported_agent_kinds`, availability and capabilities explicitly. + An absent first heartbeat means capabilities are unknown; an omitted kind in + a received heartbeat means it is not advertised. Neither permits inference. +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 +stops reconnecting. Deploy matching peers together. Removed fields, inferred +Claude availability and old interaction shapes have no compatibility path. + +Each physical connection has fresh routing, admission handles and transfer state. +A newer connection replaces the previous device connection. Core fences owner +leases and evicts old Run/interaction routes; the new connection does not inherit +them. A valid credential and connection are not authority to choose another +Session or Environment binding. + +## Envelope and identity + +Every data frame is one JSON +[`Envelope`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. + +| Identity | Scope and meaning | +| --- | --- | +| Device ID and connection | Authenticated Runtime routing and connection ownership | +| Session ID / Environment ID | Core-owned configuration and workspace binding; canonical UUIDs where required by the payload validator | +| Executor ID | Runtime-owned native resource, potentially retained across settled Turns with identical configuration | +| Preparation request ID | `Envelope.id` for prepare/start/release/status; distinct from a Run | +| Admission handle | Runtime-generated reservation, valid only on its accepting connection | +| Run ID | Execution attempt; `Envelope.id` for output, cancellation, active input and functions | +| Interaction ID | `permission_request.payload.request_id` or `prompt_for_user_choice.payload.ask_id`; these request envelopes still carry the Run ID | +| Delivery ID / input ID / call ID | Resolve attempt, active-input receipt and native function identity respectively; never interchangeable | +| Transfer ID / suspension ID | Connection-local transfer correlation / persisted suspension-attempt fencing | + +Decision and permission-cancel envelopes use the interaction ID. Cancellation and +function-result acknowledgements use the Run ID. All application decision +receipts additionally match the delivery ID. A reply without the required +correlation cannot establish acceptance. + +## Message families + +Use the linked source definitions for required fields, validators, limits and +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/runtime_prepare.go) | +| `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/outbound.go), [interactions](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/inbound.go) | +| `prompt_steer` | `prompt_steer_ack` | [Active input receipts](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/steering.go) | +| `function_result` | `function_call`, `interaction_decision_ack` | [Function calls](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/functions.go) | +| `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/workspace_read.go), [write](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/workspace_write.go), [export](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/workspace_export.go) | +| `environment_quiesce`, `environment_resume` | `environment_quiesced`, `environment_resumed` | [Suspension fencing](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/internal/agentdaemon/proto/suspend.go) | + +Initial and active input share [ordered MessageInput](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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. + +## Preparation and execution order + +Hosted and self-hosted execution use the same preparation semantics. Placement +selects a connection and Runtime-owned workspace; Harness adapters perform native +configuration. The existing `prompt_request` message remains a direct execution +operation, not a fallback after failed prepared execution. + +Environment initialization uses `runtime_prepare`. For files or archives, send +`begin`, wait for `ready`, send ordered chunks and await matching `received` +offsets, then `commit` and await `completed`. Initialization and finalization +have typed headers without file data. Validate the expected outcome, offset, +size and finite error code with the shared validator. Only one transfer is +allowed per connection. A chunk receipt confirms staged bytes, not installation. +A completed commit confirms that operation, not that a future Turn has executed. + +For an execution Turn: + +1. Subscribe to preparation status before sending `execution_prepare` with + immutable Session configuration and no Run input. +2. `preparing` means the Runtime owns preparation; `ready` supplies the Executor + ID, admission handle, revision and expiry. Neither submits user input. +3. Subscribe to the Run before sending `execution_start` with that Executor, + handle, Run ID and ordered input. A valid start transfers the reservation + once. `started` confirms transfer to the Turn, not Turn completion; output + can race status delivery and must already have a subscriber. +4. Consume Run events until a native terminal outcome or loss of observation. + An execution error is followed by `done` to close that execution stream. + `done` closes the stream; preceding errors remain part of its outcome. +5. On abandonment before start, send `execution_release`. After ownership + transfers to a Run, use `prompt_cancel`; releasing the old handle cannot + cancel its successor. + +Preparation observations use monotonically increasing revisions per handle. +Ignore older or repeated revisions; do not apply a status for a different handle. +Typical transitions are `preparing → ready → starting → started`, or termination +by `released`, `expired`, or `failed`. A `rejected` control operation has an +`operation` and error code but does not replace the resource's current revision. +Read-only workspace preparation cannot start a Turn. Expiry does not remove the +Runtime's obligation to settle cleanup. + +## What each acknowledgement proves + +| Observation | Proven fact | +| --- | --- | +| Core gateway `Send` returns nil | Envelope entered the local send queue | +| Runtime transport `Send` returns nil | WebSocket write completed locally | +| Transfer `received`, active-input `written` | Defined receive/write phase occurred; native execution or consumption is unconfirmed | +| Preparation `preparing` / `ready` | Preparation accepted / reservation ready, with no submitted Run input | +| Preparation `started` | Admission transferred to the identified Turn | +| Active-input `accepted` | Adapter confirmed native consumption under its declared receipt semantics | +| `interaction_decision_ack.applied=true` | Identified operation settled; cancellation additionally requires native settlement | +| `done` and preceding execution events | Execution stream completed with its observed outcome | + +There is no generic receipt for every envelope. A successful send is not proof +that the peer received, accepted or completed a request. Process exit, a stop +signal and a canceled local context do not prove successful cancellation. +A `done` frame may also close a settled cancellation stream; it does not +override the cancellation receipt or imply successful execution. Cancellation +receipts may retain partial content, native identity and usage in `outcome` +even when no `done` is published. Failure to obtain settlement must +remain failed or unknown; it cannot become `applied=true`. + +## Failures, retries and cleanup + +Transport and execution outcomes are separate. Core's only Run subscription +entry point is `SubscribeDurable`; inspect `Subscription.Err()` when its event +channel closes. Disconnection and subscriber overflow close it with an explicit +observation error, without fabricating `error` or `done`. Core retains durable +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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 +with `event_stream_incomplete`; both retain an unknown native effect. +This public `failed` status is not proof that the Harness failed, that no side +effect occurred or that cleanup completed. Native error classification comes +only from observed Runtime error frames. Consumers must keep the observation +reason and any native evidence distinct; this contract does not add a public +`unknown` status or change the existing Turn state machine. + +| Condition | Required responsibility | +| --- | --- | +| Unsupported capability or invalid binding | Reject explicitly before starting the unsupported operation; do not select another Harness | +| Confirmed preparation/execution failure | Preserve the finite error category and any observed result; Runtime settles its resources | +| Deadline or connection loss after dispatch | Caller has an unknown effect unless an application receipt proves otherwise; do not convert it to execution failure | +| Reconnection | Reestablish transport and capability advertisement; do not replay input, initialization, transfers or unresolved mutations | +| Duplicate preparation/start | Existing connection-local identity and fingerprint rules apply; a conflicting request rejects and an old handle cannot start replacement work | +| Duplicate input/function/decision | Use that family's existing receipt identity and conflict rules; no transport-wide deduplication or exactly-once promise exists | +| Cleanup failure | Retain resource ownership and report unconfirmed cleanup; a resource is not reusable merely because a waiter timed out | +| Lost cancellation receipt | Cancellation may have happened; lack of receipt cannot establish success or authorize another execution | + +Only retry operations whose own contract establishes that retry is safe. Retired +preparation request IDs may eventually allocate a new handle, so they are not +durable idempotency keys. Native-session resume is an explicit operation with +verified identity, not a response to a socket failure. A transient connection +error permits reconnecting the channel; authentication/version rejection requires +operator correction. Cleanup of an Executor remains separate from the Sandbox +Provider's confirmed reclamation of compute. + +## Contract verification + +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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`. + +The suite checks incompatible versions, preparation failure, cancellation +settlement, connection loss without invented terminal events, reconnect without +replay, stale/duplicate handles and receipts, cleanup failures, bounded transfer +validation and resource ownership after timeout. Existing detailed fault +injection remains next to the owning gateway/dispatcher implementation. + +For another Runtime or Harness, reuse these protocol sequences and assertions, +then run its own native acceptance for the capabilities it advertises. A passing +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. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/runtime-protocol.md) diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx new file mode 100644 index 000000000..5365eb82c --- /dev/null +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -0,0 +1,188 @@ +--- +title: "Add a Sandbox Provider" +description: "Environment allocation, bootstrap and reclamation." +--- + +Start at [`sandbox_provider.go`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/sandbox/sandbox_provider.go). +It defines the required `SandboxProvider` and optional `CheckpointProvider` +interfaces. Use the existing types; do not introduce another lifecycle protocol or +an execution path for a particular vendor. + +Core owns durable Environment, allocation, placement and cleanup state. A Sandbox +Provider supplies compute and the bootstrap needed to start its Runtime (daemon). +Runtime prepares capabilities and workspaces through the common daemon protocol; +Harness adapters translate execution into native operations. The same Runtime +preparation and execution contract applies to a user-owned machine. That machine +has no Core-owned compute allocation to create or destroy. + +Sandbox isolation belongs to the outer infrastructure. A daemon running directly +on a user's machine runs with that user's authority; installing the daemon does +not establish a filesystem, tool or network sandbox. Provider isolation and +network enforcement must be qualified independently of daemon connectivity. + +## What to implement + +| Contract | Requirement | Responsibility | +| --- | --- | --- | +| `sandbox.SandboxProvider` | Required | `Create`, `GetInfo`, `Renew`, `Kill`, and bounded bootstrap/diagnostic `RunCommand` | +| `sandbox.CheckpointProvider` | Optional, separate interface | Exact compute incarnations, snapshot capture/restore, retained-source resume and cleanup | +| `runtimeobs.Source` | Optional, separate interface | Read-only, ownership-checked resource observations | +| `runtimeobs.BatchSource` | Optional, separate interface | Bounded observations in input order, with per-target errors; `ok=false` means no batch read occurred | + +Do not implement an optional interface with successful no-op methods. Core uses +interface assertions to select optional operations. Advertised support requires +contract and native acceptance evidence; a healthy node or an available CLI does +not establish it. Observation never renews a lease, starts compute or prepares a +Harness. See the [observation contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/runtime-observability.md). + +`RunCommand` is an existing administrative bootstrap/diagnostic facility, not an +alternate route for Skills, Plugins, MCP setup, initial files, Session execution or +Files. Those use Runtime. Confidential command input travels in `Command.Stdin`, +not argv or logs. Preserve its byte order, bounded output and actual exit status. + +## Register an adapter + +Registration is explicit construction, not an init-time plugin registry: + +1. Put native SDK calls, ownership checks, identity translation and private + configuration in an adapter package under `services/agents-api/internal/sandbox`. + Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. +2. For a node-local adapter, add its typed construction and readiness probe in + [`sandbox/config`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/sandbox/config/config.go). + The existing `Built` result returns the provider, probe, installation identity, + backend fingerprint and specification digest; the factory also returns its + close function. Construction must not create an Environment. +3. For a direct adapter, register its construction in + [`managedSetup.provider`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/cmd/server/managed_setup.go). + Node-local adapters are selected there through the existing node proxy. Keep + vendor-specific deployment validation and SDK setup at this boundary. +4. Preserve the existing `execution.RuntimeProvider` deployment binding: + `ProviderKind`, installation ID, backend fingerprint, generation, mode and + node ownership identify the selected backend. The database owns the selection; + the in-memory selection is not an alternate authority. Register optional + interfaces on direct adapters and their transport wrappers consistently. +5. Qualify the provider's operator configuration and resource limits in the + existing deployment specification/enrollment path. The shipped deployment + parser and persistence constraints explicitly enumerate supported backends; + update that registration surface when adding one. Do not bypass it with an + arbitrary provider string. This is deployment admission, not a new execution + workflow. Adding a new public capability requires its own protocol change. + +Today Docker and microsandbox use nodes; E2B is constructed directly. The node +proxy exposes checkpoint operations only for its registered checkpoint-capable +backend. A new node backend must register both construction and the corresponding +proxy capability; otherwise that capability is unavailable. Provider names belong +in this adapter/configuration wiring, not Session scheduling, capability +preparation or Turn execution. Common lifecycle code uses `CheckpointProvider` +to admit suspension, independently of a provider name. + +The backend fingerprint identifies a native resource namespace, not mutable +capacity. Core retains deployment generations so old owned allocations continue +to resolve to their original backend. This is resource ownership, not historical +binary compatibility. Do not repoint retained allocations at a replacement backend. + +## Identity and resource responsibility + +`Reference` is the exact `(TenantID, EnvironmentID, AllocationID)` tuple. Core +persists a fresh allocation ID **before** `Create`; it is not the Environment ID. +The adapter also binds resources to its installation. Do not locate or authorize +resources by a bare native ID, display name, guessed path or an unverified label. +All mutations, reads and cleanup must verify the same ownership. + +Core serializes lifecycle operations and retains the allocation after any +uncertain mutation. An adapter must preserve enough native identity/receipts for +observation and cleanup. A failed call may return both `Info` and an error: +preserve reference-bound settlement evidence without converting failure to success. +Adapters must not silently create replacement resources, overwrite credentials or +switch to a new allocation after a conflict. + +`Info.ProviderID` and `Info.State` describe compute. Core recognizes `running` +only with the matching Reference and a native identity. Other native states can +be observed without claiming readiness. `BootstrapComplete` says the bootstrap +reached its final mutating step. `CreateSettled` proves the original attempt can +no longer mutate resources; it does not mean success. In particular: + +- `State="absent"` plus matching Reference and `CreateSettled=true` is an explicit + creation-absence receipt, with no native ID or completed bootstrap. +- `ErrNotFound`, an empty listing, a timeout or a successful `Kill` alone is not + proof that an in-flight create cannot appear later. +- Core can also settle creation from a matching running resource with completed + bootstrap. It must retain unknown creation until it has such evidence or an + explicit receipt, even if a cleanup attempt currently sees no resources. + +`Kill` owns cleanup of the allocation's compute and retained resources, including +partial bootstrap storage. It must not remove another tenant's resource on a +name collision. Core releases durable ownership only after confirmed cleanup +**and** settled creation. Executor release or Harness cancellation does not delete +an Environment, its workspace or its Sandbox Provider allocation. + +## Operation outcomes and retries + +All calls receive bounded contexts. Expiry or cancellation ends the caller's +wait; it does not prove rollback, native stop, cleanup or absence. An adapter or +transport must not detach untracked mutations or replay a timed-out command. + +| Operation | Confirmed result | Failure or unknown result | Recovery | +| --- | --- | --- | --- | +| `Create` | Matching compute and bootstrap evidence; execution still needs Runtime preparation | Invalid/foreign configuration rejects; duplicate returns `ErrExists`; transport failure may hide created resources | Observe the original Reference. Never replay `Create`, even with a new credential. Preserve partial resources for owned cleanup. | +| `GetInfo` | Current compute observation without mutation | `ErrNotFound` is only a missing observation; an error is not absence proof | Repeat a bounded read; never turn it into create/start/renew. | +| `Renew` | Existing native lease extended, or an observation for a provider without leases | Timeout may hide a lease extension; stopped/missing compute stays stopped/missing | Observe then let the normal reconciler renew the same allocation. Never revive it or fabricate a lease expiry. | +| `Kill` | Owned compute and retained storage removed; repeated confirmed absence succeeds | Error retains ownership and cleanup intent; ownership mismatch must not delete foreign resources | Retry cleanup of the same Reference after outstanding creation/mutation is fenced. Do not release the durable owner early. | +| `RunCommand` | Collected output and actual exit code; nonzero exit is a settled command failure | Missing native completion is `ErrCommandUnconfirmed`; partial output is not success | Do not replay. Preserve the owner and reclaim before reuse when completion cannot be proved. | + +Use `ErrInvalid`, `ErrOwnership`, `ErrExists`, `ErrNotFound`, +`ErrComputeUnconfirmed` and `ErrCommandUnconfirmed` for their existing meanings. +An unclassified native/transport error is conservatively unknown, not permission +to retry a mutation. Core must not interpret provider diagnostics as lifecycle +truth or expose native error text/credentials. The node boundary maps errors to +fixed codes; direct SDK details stay private. + +Checkpoint support adds `Compute` generation/name/ID and `SnapshotIdentity`. +Persist operation IDs and provider-returned snapshot provenance unchanged. +`ObserveOnly` on suspend/resume observes the previous attempt and must not start +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/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/internal/execution/runtime_compute.go) +and its failure tests before advertising this capability. + +## Four distinct readiness facts + +| Fact | Evidence | Does not establish | +| --- | --- | --- | +| Compute available | Provider observation for the owned allocation | Authenticated Runtime connection or prepared capabilities | +| Runtime connected | Gateway authentication and exact Environment/device binding | Completed Runtime preparation or a usable Harness | +| Capabilities prepared | Successful common Runtime preparation with the fixed configuration/snapshot | Acceptance or completion of a Turn | +| Execution admitted | Qualified Harness capabilities and the existing executor/Turn acceptance path | A completed input, cancellation or reclaimed compute | + +For both hosted and self-hosted environments, Core uses the daemon preparation +and execution protocol. Core resolves configuration and supplies resources; +Runtime installs/reads local capabilities and freezes the result for the Session. +Reconnection can reuse that prepared content; a new Session takes a new snapshot. +A provider must not implement a competing preparation path. + +## Validate the integration + +Run `make check-sandbox-provider-contract` while developing. It runs the shared +[`contracttest`](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/services/agents-api/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, +not substitute a fake implementation of `SandboxProvider` for the adapter under +test. Preserve tests for foreign ownership, unknown mutation results, cancellation, +no automatic replay, failed cleanup and reference-bound settlement. + +Node tests separately exercise disconnect/reconnect fencing and cleanup after a +lost create response. Provider helper protocols and the node protocol require an +exact version match and reject mismatches; do not add fallback decoders or old +binary migration. Direct in-process interfaces have no independent wire version. + +Run `make check` with its dedicated database before completion. Retain native +acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, +owned partial cleanup, declared isolation/limits, and snapshots where supported. +The opt-in Docker lifecycle/recovery tests use `AGENTS_RUNTIME_DOCKER_TEST_IMAGE`; +SDK helper tests use `make check-e2b-provider` and +`make check-microsandbox-provider`. Passing controlled contract tests is not a +claim of live cloud or model acceptance. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/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 3e81ea259..75a63ed43 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -3,36 +3,28 @@ title: "Self-hosted executors" description: "Connect a user-owned Runtime to its Environment with a restricted executor credential." --- -A `self_hosted` Session runs on a machine the application owns, the executor host, -instead of a node. The application creates the Session through `/v1`; the -administrator issues an executor credential for it in Web; one command on the host -starts a Runtime container that connects to Core. The Runtime is the same one nodes -use: daemon, native harness and workspace. - -An executor credential works for one Session's environment only. It can't call the -Agent API, the Core API or node enrollment. - -## Before you start - -- **Core has an HTTPS public URL.** The Session's `remote_url` is - `wss:///api/v1/agent-daemon/ws`, and the installer connects only over - `wss://`. Web shows no command until Core has a reachable public URL. -- **Web holds the installer and the Runtime files.** The command downloads the - installer, the Runtime launcher and the image from Core's public URL, which the - reverse proxy sends to Web (in a [split deployment](/install#split-deployment), to - the Web host); install from the offline bundle. A console without the self-hosted - installer shows no **Connect a host** section at all. -- **The host** runs Linux amd64 with Python 3.9+, `curl`, `sha256sum` and the Docker - CLI, with Docker usable through `/var/run/docker.sock` by the non-root user that runs - the installer; the installer refuses root. It reaches the public URL over HTTPS. -- **The Session carries its model provider**, in the request or saved on its Agent. - Default models never apply to self-hosted Sessions, and creating one without a - provider fails with 400 `model_provider_required`. +A `self_hosted` Session runs on a machine the application owns. The application +creates the Session through `/v1`; the administrator issues an executor credential +in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers +remain Linux-only. + +The daemon runs with its launching user's permissions and adds no inner +filesystem, permission or network sandbox. Its tools may access that user's files +and Runtime credentials. Managed isolation belongs to the outer Environment; +self-hosted operators choose any outer isolation they need. See the +[native installation guide](/self-hosted-native) for prerequisites, commands +and supported platforms. + +An executor credential works for one Environment only. It cannot call the Agent +API, Core API or node enrollment. Default model providers never apply to +self-hosted Sessions: the request or Agent must carry its own model provider. +Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. **The application creates the Session**, with its Project API key and its own model - provider: +1. Create an existing workspace on the executor host. The application creates a + Session using that host's absolute path and its own Project API key: ```python import os @@ -40,7 +32,10 @@ Agent API, the Core API or node enrollment. client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY session = client.beta.agents.sessions.create( - environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + environment={ + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + }, extra_body={ "agent": {"model": os.environ["MODEL_NAME"], "x_agents_core": {"harness": "codex"}}, "x_agents_core": {"model_provider": { @@ -53,50 +48,61 @@ Agent API, the Core API or node enrollment. print(session.id, session.environment.id, session.environment.remote_url) ``` -2. **In Web**, open **Session log**, then the Session's page. In its - **Executor credentials** section, **Connect a host** shows a command in a - **Terminal** block; copy it and run it on the executor host: - - ```sh - (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && - printf '%s %s\n' '' "$d/install.pyz" | sha256sum -c --status && - python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id '' --remote 'wss://core.example/api/v1/agent-daemon/ws') - ``` +2. In Web, open **Session log**, then the Session's **Executor credentials** + section. Choose **Issue credential**, then **Download credential file**. Core + returns the credential only once; retain it privately before choosing **Done**. +3. Extract the native distribution and run `oac-daemon install --interactive` + (or supply all options with `--non-interactive --harness`). Use the returned + remote URL, Environment ID, matching workspace and credential file path, then + start the installed `bin/oac-daemon`. + The [native guide](/self-hosted-native#install-and-start) has Linux/macOS and + PowerShell examples. No Docker installation is required for this native path. + +The console's **Connect a host** flow provides native installation guidance and +the executor credential download. Obtain the matching native distribution before +running its command. Core must be reachable from the host: `wss://` is required +outside loopback, while a local Core can use a loopback `ws://` URL. + +A connected Environment proves only the machine connection. Send a Turn to check +the selected harness and model. Core supplies the Session's model provider over +this Environment's authenticated executor connection. The host's user and tools +running with that user's permissions can access locally stored Runtime data. + +## Local capability directories + +A self-hosted Session may supply `capability_directories` alongside its workspace: + +```python +environment = { + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + "capability_directories": [os.environ["EXECUTOR_CAPABILITIES"]], +} +``` - The command contains no secret. It downloads the installer from your console and - checks its SHA-256 before running it. -3. **Issue the credential.** Select **Issue credential**. The **Executor credential** - dialog shows it once: choose **Copy credential**. -4. **Paste it at the installer's hidden prompt** and press Enter. The prompt accepts the - compact or the pretty-printed credential and never echoes it. Then choose **Done** in - the dialog; after that the credential can't be shown again. Closing the dialog with - × keeps the credential on the page until you choose **Done**. - -The installer downloads the Runtime from the console and checks it, starts a container -named `oac-selfhost-<32 hex digits>`, and waits until Core confirms that the -Environment is connected. The Session then runs its Turns there. Rerunning the same -command resumes the same installation. It refuses, with "This installation belongs to -another Environment or distribution", once Core runs another release or its public URL -changed: the host's installation is tied to both. Moving an executor to a new release -or address isn't supported yet. A fresh installation would start a new container -without the old workspace and native history, so the Session can't continue there: -stop the old container, create a new self-hosted Session and connect a host for it. - -For automation without a terminal, choose **Download credential file**, which saves -`executor-credential-.json`, make it private -(`chmod 600`), and add `--credential-file /absolute/path/to/that-file.json` to the -installer line. The path must not go through a symbolic link. - -The host keeps its state in `~/.oac/self-hosted//` -(`--install-dir` chooses another absolute directory). Keep it with the container's -volumes: they hold the credential, workspace and native history. The Runtime's log is -`docker logs `. A connected Environment proves the connection, not that the -model works; send the Session a Turn to test execution. - -Core delivers the Session's model provider only over this Environment's executor -connection. The host keeps it in the Runtime's harness home, where the model's tools -and the Files API can't read it but the host's owner can. +Core accepts absolute Unix, Windows drive and UNC source paths without checking +its own filesystem. Runtime validates them using the executor host's path syntax. +Populate these directories before first execution. They are ordinary paths visible +to that process; naming a directory does not mount it or create a sandbox. +Self-hosted public input accepts local directories, not managed Skill/Plugin +archives or hosted Templates. + +Runtime snapshots sources before native execution and uses the same parser and +`installed.json` format as managed bundles to supply Skills and Plugin MCP. +The native installer defaults the snapshot destination to `capabilities` under +`OAC_RUNTIME_HOME`; `--capability-directory` selects another local destination. +It is an operator setting, not a public API write destination. Reconnect reuses +installed contents even after source edits; a new Session captures its own +configuration. A missing or inconsistent snapshot fails preparation rather than +silently reinstalling. Local discovery does not create entries in the public +API-managed installation arrays. Snapshot file modes do not isolate the snapshot +from tools running as the same user. + +Closing an executor, cancelling a Turn or losing its connection preserves the +snapshot and workspace. The compute owner remains responsible for explicit +cleanup. [Historical qualification](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/user-managed-runtime-v1.md) +records only its stated inputs and binaries; it does not qualify the current native +platforms or local capability preparation. ## Rotate or revoke @@ -105,25 +111,16 @@ and the Files API can't read it but the host's owner can. | **Rotate** | The credential gets a new secret; the old secret stops working at once. Rotating a revoked credential restores it | | **Revoke** | The credential stops working at once | -After either, the Runtime disconnects and stops retrying: its log shows one message -that names the fix, and the container keeps running without a restart loop. To -reconnect the host: - -1. **Rotate** the same credential in Web and copy the new secret. -2. Rerun the same install command on the host and paste it. +To reconnect a native installation, stop it with `oac-daemon stop`, rotate the same +credential, replace the JSON at its configured credential-file path, and run +`oac-daemon start`. Keep the same `OAC_RUNTIME_HOME` for every command. Issuing a +new credential does not reconnect an Environment already bound to its first +credential; rotate that credential instead. Do not run `install` again over the +existing installation. -The installer puts the new credential into the same container, which reconnects with -its workspace and history. Issuing a *new* credential doesn't reconnect an Environment -that has already connected: it stays bound to its first credential, and the installer -says to rotate that one. - -To remove the Runtime instead, stop its container: -`docker --host unix:///var/run/docker.sock stop `. Deleting the Session does -not remove containers or volumes on the executor host. - -In an archived project, Web shows "This project is archived, so executor credentials -can't be issued or rotated." Only **Revoke** remains; a host that already holds a -credential keeps it when you rerun the command. +Stopping the daemon keeps its workspace and native history. Deleting a Session +does not remove host files. In an archived Project, credentials cannot be issued +or rotated; revocation remains available. ## Without Web @@ -143,27 +140,15 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-executor-credentials.md) has every rule and error. +## Historical executor installations -## Executors installed before the rename - -An existing `parsar-selfhost-*` container keeps its own image, daemon and -`~/.parsar/self-hosted/` directory. It can keep serving its -Environment after Core is renamed; Core does not upgrade or manage this container. - -The new installer refuses when that exact Environment still has a directory under -`~/.parsar/self-hosted`, including when a different `--install-dir` is supplied. -Executors for other Environments are unaffected. To replace it, inspect the old -installation's `started.json` to identify its container, stop and remove that -exact container, and preserve any history you need before removing the old -Environment's installation directory. Then rerun the new verified installer. It -starts `oac-selfhost` with state under `~/.oac/self-hosted/`; -it does not reuse the old executor's volumes or native history. - -Credential replacement for executors created by the new installer still updates -and restarts the same owned container. The legacy refusal does not authorize -adopting an old container to replace its credential. +The native installer supports its current version only. It does not adopt or +upgrade an older daemon or container installation. Preserve old files, containers +and native history; use a fresh Runtime home and Session when moving versions. +Historical container-installer instructions and qualification records describe +those earlier artifacts, not acceptance of the current native daemon. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/self-hosted-execution.zh.mdx b/apps/docs/content/docs/self-hosted-execution.zh.mdx deleted file mode 100644 index 27012a4b9..000000000 --- a/apps/docs/content/docs/self-hosted-execution.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Self-hosted executors · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/self-hosted-execution)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx new file mode 100644 index 000000000..0d2473071 --- /dev/null +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -0,0 +1,189 @@ +--- +title: "Native daemon installation" +description: "Install and operate a self-hosted daemon on Linux, macOS or Windows." +--- + +Use the same `oac-daemon` on a user-managed Linux, macOS or Windows machine. +Physical machines, VMs and user-owned sandboxes use the same installer. Core does +not create or reclaim these machines. Core-managed Docker, E2B and microsandbox +Providers remain Linux-only and receive prebuilt Runtime images or templates. + +The daemon runs with its starting account's permissions, without an inner sandbox +or privilege elevation. Tools can access whatever that account can access. Use an +outer container or VM when isolation is required. Connection authentication, +Project/Environment authorization, credential permissions, Files path validation, +process cleanup and snapshot consistency still apply; they do not isolate tools +from their own account. `disabled` and `restricted` network modes require an outer +implementation that enforces them. + +## Platforms and prerequisites + +| Platform | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| Linux | Supported | Supported | Supported | +| macOS | Supported | Supported | Supported | +| Windows | Supported | Supported | Unsupported by the current adapter | + +Use a distribution built for the machine's OS and architecture. Windows support +is validated on a native CI runner; manual Windows machine acceptance is not yet +recorded. Native Linux/macOS runs and native CI qualify the corresponding bundles. +Supported does not mean every model provider or optional native feature works in +every combination. Session capabilities are checked by the existing Harness contract. + +The distribution contains Node 22.22.0/npm and the selected release's components: +Codex 0.153.4, Claude Agent SDK 0.3.269 with native Claude Code 2.1.269 and the +project's adapter, and the patched MiniMax Code 0.4.12 companion on Unix. Arbitrary +official CLI installations are not adopted. They remain untouched while the +installer creates its private, verified copy. Compatible components from this +same installation are reused after checking their version, contents and startup. + +Claude on Windows requires Git Bash. Bash is also needed for Runtime setup and +MiniMax tools. Node/npm and MiniMax's ripgrep are included; Python/pip, when needed +by a Session's capability dependencies, must be available. Missing system +components are reported. Install those through the host's normal administration +process; the daemon never runs apt, sudo or an elevation command. + +## Obtain a distribution + +Before a public release exists, build a native package with the repository's +`native-installer` workflow and download its artifact for the target platform. +Extract the archive before running the executable. The workflow publishes CI +artifacts, not a production release or an automatic update channel. + +For a local release build, use `scripts/build-native-installer.mjs --daemon PATH +--node DIR --codex DIR --claude DIR --minimax DIR --output ABS`. Select the +components to include; omit MiniMax on Windows. Build on the target OS, using +the existing pinned native artifacts. The builder verifies native startup and +creates `bundle.json`, `oac-daemon` (or `.exe`) and `components/`. +`bundle.json` describes release files and checksums. It does not accept user +installation options, connection settings or credentials. + +The Claude source is the existing compiled adapter export from modern +`pnpm deploy`. Reify its dedicated frozen lock with +`pnpm install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts` +in a fresh export before packaging. This preserves dependency resolution when +the builder converts contained links to regular files. MiniMax must be built +from the pinned source with this revision's patches and native dependencies; +copying a Linux companion onto macOS does not produce a macOS distribution. + +## Install and start + +Create a `self_hosted` Session with its model provider and an existing absolute +`workspace_directory` on the executor host. Save its `environment.id` and +unchanged `environment.remote_url`. Have the administrator issue an +[executor credential](/self-hosted-execution#without-web) and save the JSON +as a private file. It contains `key_id`, `environment_id` and `executor_token`. +Pass the file path, never the token. Use `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The workspace must match the Session. + +From the extracted distribution, interactive installation asks for Harnesses +(comma-separated, multiple selections allowed), installation directory, workspace +and connection settings. Existing command-line values also work with the prompts: + +```sh +./oac-daemon install --interactive +``` + +Noninteractive installation accepts command-line arguments only and never waits +for input. On Linux or macOS: + +```sh +mkdir -p "$HOME/agent-workspace" +chmod 600 "$HOME/executor-credential.json" +./oac-daemon install --non-interactive \ + --harness codex,claude,minimax \ + --install-dir "$HOME/.oac/runtime-example" \ + --remote 'wss://core.example/api/v1/agent-daemon/ws' \ + --environment-id '11111111-2222-4333-8444-555555555555' \ + --workspace "$HOME/agent-workspace" \ + --credential-file "$HOME/executor-credential.json" +"$HOME/.oac/runtime-example/bin/oac-daemon" start +``` + +On Windows, use native absolute paths in PowerShell: + +```powershell +New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null +.\oac-daemon.exe install --non-interactive ` + --harness codex,claude ` + --install-dir "$HOME\.oac\runtime-example" ` + --remote 'wss://core.example/api/v1/agent-daemon/ws' ` + --environment-id '11111111-2222-4333-8444-555555555555' ` + --workspace "$HOME\agent-workspace" ` + --credential-file "$HOME\executor-credential.json" +& "$HOME\.oac\runtime-example\bin\oac-daemon.exe" start +``` + +Replace the URL and UUID with the Session's values. Keep the credential file in +the account's private storage. The installer does not print credential contents +or pass them in the background daemon's arguments. + +| Option | Meaning | +| --- | --- | +| `--harness` | Comma-separated `codex`, `claude`, `minimax`; unsupported combinations fail | +| `--install-dir ABS` | User-writable installation; defaults to `OAC_RUNTIME_HOME`, then `~/.oac` | +| `--bundle-dir ABS` | Extracted release directory; defaults beside the executable | +| `--remote`, `--environment-id`, `--workspace`, `--credential-file` | Required connection inputs, equally available in both modes | +| `--capability-directory ABS` | Capability snapshot destination, default `capabilities` under the installation | +| `--tool-env-file ABS` | Optional JSON object of string-valued tool/MCP variables, not installation options | + +Installation reports three independent facts: local installation readiness, +connection not yet checked, and model configuration not yet checked. It does not +start a daemon, provision a machine, configure a model or create an OS service. +`start --foreground` runs under an operator's preferred service manager. + +## Add Harnesses and operate the installation + +Run the original distribution's install command again with identical connection +options and the Harnesses to add. The installer retains already selected Harnesses, +checks compatible existing contents and adds only missing components. No default +deletion, replacement or upgrade occurs. All installation writes use one lock. +A component is published only after its copy passes checksum verification; +interrupted additions can reuse complete components on the next run. Installation +settings are committed only after all selected Harnesses pass readiness checks. + +The installed `bin/oac-daemon` locates its own installation. Use that executable +for `start`, `status`, `logs -n 100`, `logs -f` and `stop`. Direct `connect` is +rejected for an installed Runtime; `start` validates its components and selection. An explicit +`OAC_RUNTIME_HOME` overrides this location; keep it consistent if set. If adding a +Harness while the daemon is running, restart it to refresh Harness discovery. +`status` reports local profile/PID-file information only. Check **Host connection** +in Core or the Environment connection API for authenticated connection state. + +A connected Environment proves machine authentication, not model availability. +Configure the model provider through the existing Session/Agent mechanism and +send a Turn to verify execution. Rotate a credential by stopping the daemon, +replacing the JSON at the configured path with the rotated credential for the +same `key_id`, and starting again. Revocation blocks the old credential. + +An incompatible daemon version, component version or modified installation is +an explicit error. Use a separate installation directory; there is no old-version +upgrade, migration or automatic repair. Preserve previous files and history. +Stopping a daemon, cancelling a Turn or deleting a Session never removes the +user's machine, workspace, native history or capability snapshot. + +## Common preparation and execution + +After authenticated connection every Runtime follows the same flow: Harness +availability, workspace and capability preparation, fixed `installed.json`, then +execution, cancellation and recovery. Provider image contents and self-hosted +`capability_directories` enter the same parser and installation result. Adapters +receive Skill paths, Plugin results and MCP declarations from that snapshot. +Reconnect reuses it; new Sessions capture new configuration. Preparation or +recovery errors never trigger silent reinstall, replay or replacement native Sessions. + +Runtime initialization/package directories default to `initialization` and +`packages` under the installation. Managed images use their own storage layout +through `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +this does not change the preparation protocol or account permissions. npm and +Python dependencies use user-writable prefix/target directories. Setup uses Bash, +including Git Bash on Windows. Supplying `packages.system` in configuration is +rejected even when empty or null; managed images/templates must include system +dependencies before launch. Official API read responses retain the required +`system: []` field, which does not imply support for installing system packages. + +On Windows, stdio MCP commands named npm or npx (including explicit .cmd +paths) run through the selected installation's JavaScript entrypoint with Node. +Other batch wrappers require an explicit cmd.exe command and its arguments. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/self-hosted-native.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 36944b8b4..dfc574ff2 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -88,10 +88,13 @@ read. `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated -Runtime. For a `self_hosted` Session, the deployment operator issues the +Runtime on Linux, macOS or Windows; managed Providers remain Linux-only. Native +Runtime execution uses the launching user's permissions without an inner sandbox. +See [native installation and validation status](/self-hosted-native). +For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/sessions.zh.mdx b/apps/docs/content/docs/sessions.zh.mdx deleted file mode 100644 index 87e48cbf8..000000000 --- a/apps/docs/content/docs/sessions.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Sessions and public resources · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -Session 是持久对话上下文,绑定一个 Environment。输入事件启动或引导 Turn。Session、Turn 和 Environment 的状态各有含义,不应互相替代;具体字段以公共参考为准。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/sessions)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index f4f07f235..8a5919c71 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -132,7 +132,7 @@ application, then **Revoke** the old key. **Archive** disables all of a Project' and keeps its assets and accepted work. Administrators can inspect and delete retained resources but can't execute with the Core key. -## Data and upgrades +## Data and current-release repair ### Back up @@ -162,7 +162,7 @@ and provider storage rather than registering over existing resources. Never prun Docker volumes or delete native history to make a retry pass. A deleted Session does not prove that all provider resources were reclaimed. -### Upgrade an installation +### Installation version policy In-place version upgrades, downgrades and historical conversions are not supported. Keep existing data and installations intact; install the new release into a new, @@ -174,49 +174,20 @@ and mutating `oac` commands use the same installation lock, including during rep and interrupted apply recovery. If another command owns it, retry after it finishes. Never remove or replace `.oac.lock` to bypass a busy installation. -### Convert an earlier installation - -Historical conversion is not supported. `--convert` refuses without changing the -installation. Preserve its files and data, and reinstall separately. - -### Upgrade notes - -Retired process settings remain rejected. Configure a fresh installation using the -current [configuration reference](/configure); do not copy old generated -files into it. - -#### Node connections at /api/v1 - -Core and its nodes must come from the same distribution; the node installer refuses a -mismatched release. Current releases serve every machine connection at `/api/v1`, and -Web returns 404 there. When the new Core and Web go live, route `/api/v1/*`, including -WebSocket upgrades, to Core instead of Web -([HTTPS and the reverse proxy](/install#https-and-the-reverse-proxy)); with the old -routing, node enrollment and every Runtime connection fail, for Docker, microsandbox, -E2B and self-hosted executors alike. - -Nodes from releases that used the removed `/core/v1/sandbox/enroll` and -`/core/v1/sandbox/node/*` paths can't connect to a current Core. For a Docker or -microsandbox deployment: - -1. Drain with the previous release while its nodes are connected: enter maintenance, - archive retained hosted Sessions and wait until nothing is retained using that - previous release's maintenance procedure. -2. Upgrade Core and Web, and change the routing as above. -3. Reset a drained selection that lacks a specification, then configure the new - release's Runtime and add nodes again. On each node host, - stop the old node service and move its state directory aside as a backup first. - -A Web-selected Docker or microsandbox deployment saved before deployment specifications -existed has an empty specification after migration. Draining it needs a Core that has -the pre-specification drain mode (pull request #114) but still serves the old node -paths. No such release was published: build a distribution from main commit -`7b66be236a627246c85658722314285e6b39d9b8`, or any commit that contains #114 but not the -move to `/api/v1/sandbox-node`. That Core loads the deployment only to drain; fresh -sandboxes, node configuration reads and enrollment are refused. Back up, replace Core -and Web with that build, drain as in step 1, then upgrade to the current release and -continue with steps 2 and 3. Node IDs change; Session history and persisted Files and -Artifacts remain. E2B deployments from that period are not covered. +Historical installations and removed CLI or machine-protocol variants have no +migration procedure. Preserve their files, database, Runtime resources and Session +history; use a fresh installation and separately provision its nodes. Reinstallation +does not authorize deleting the previous installation's data. Retired process +settings remain rejected; use the current +[configuration reference](/configure). + +### Machine connections + +Core and its nodes must come from the same distribution; the node installer refuses +a mismatched release. Route `/api/v1/*`, including WebSocket upgrades, to Core; +Web returns 404 there. This applies to Docker and microsandbox nodes, E2B and +self-hosted executors. See +[HTTPS and the reverse proxy](/install#https-and-the-reverse-proxy). ## Troubleshooting @@ -228,8 +199,7 @@ Artifacts remain. E2B deployments from that period are not covered. | `Port N is already in use; select another port` | Free the port, or install with `--core-port`/`--web-port` | | `Installation directory is not empty …` | Use an empty `--install-dir` | | `This installation is configured by …/config.json …` | Flags only seed a new installation: edit `config.json` and run `oac apply` | -| `This installation predates config.json …` | [Convert it](#convert-an-earlier-installation) | -| `This installation runs another release …` | See [Upgrade an installation](#upgrade-an-installation) | +| `This installation version or historical conversion is not supported …` | Preserve the installation and data; [install separately](#installation-version-policy) | | `generated/ was edited by hand` | Put the change in `config.json`, then `oac apply --discard-edits` | | `config.json has changes that are not applied` | Run `oac apply` | | `Core rejects secrets/core.key …` | Run `oac apply`, which restarts Core with the key's digest | @@ -255,11 +225,14 @@ sign-in sessions live in Web's memory and last 12 hours. microsandbox uses an explicit network policy: public egress, the Core and DNS ports the Runtime needs, and no inbound or private-network access. Private model or MCP endpoints -need an explicit policy change. A Session's own network policy is separate. +need an explicit policy change. The daemon does not enforce Session-level +`disabled` or `restricted` networking. Combinations without the required outer +network enforcement are unsupported; a requested mode is not an isolation guarantee. -The Docker node uses the nested-sandbox Runtime policy. Its service account needs the -host's Docker daemon, and Core has no Docker socket. Install nodes on trusted hosts and -don't share their Docker access with untrusted users. +Docker isolates the Runtime at the outer container boundary. The daemon does not +add an inner filesystem, permission or network sandbox. The node's service account +needs the host's Docker daemon, and Core has no Docker socket. Install nodes on +trusted hosts and do not share their Docker access with untrusted users. A Core restart leaves node services and resident microVMs running. A host reboot, the end of a user manager or the loss of a running microVM is not a Core restart and is not @@ -271,9 +244,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/getting-started/operations.md) diff --git a/apps/docs/content/docs/troubleshooting.zh.mdx b/apps/docs/content/docs/troubleshooting.zh.mdx deleted file mode 100644 index 2d33d69a6..000000000 --- a/apps/docs/content/docs/troubleshooting.zh.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "Operations and troubleshooting · 中文导读" -description: "当前版本的中文导读;完整操作步骤见英文正文。" ---- - -使用 oac status 查看安装状态,oac start 和 oac stop 管理服务,oac apply 应用 config.json。备份必须同时保留数据库、配置和密钥。只支持全新安装和同一版本修复,不支持原地升级、降级或历史转换;保留旧安装和数据,在新的空目录重新安装。安装、修复和修改状态的 oac 命令共用 .oac.lock;遇到占用应等待当前命令结束,不要删除或替换锁文件。 - -此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/troubleshooting)。 - -[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/user-guide.mdx b/apps/docs/content/docs/user-guide.mdx new file mode 100644 index 000000000..94dbefd39 --- /dev/null +++ b/apps/docs/content/docs/user-guide.mdx @@ -0,0 +1,120 @@ +--- +title: "Use OpenAgentCore" +description: "Sessions, capabilities, files, cancellation and recovery." +--- + +Complete the [first Session walkthrough](/quickstart) before using +this guide. It provides `client` and `session`; the examples below continue that +Session. For exact request fields, use the [API reference](/public-api). + +## Choose where a task runs + +| Environment | What you prepare | Next step | +| --- | --- | --- | +| `openai_hosted` | Ready managed capacity and a configured model | Core creates the Environment and connects its daemon | +| `self_hosted` | Your machine, workspace, daemon and model provider | [Connect the Runtime](/self-hosted-execution) to the returned Environment | +| `none` | An existing device connection and its configured Harness | Check the qualified profile in [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/harness-selection.md) | + +Use the [Environment contract](/environments-and-files) for placement, +expiry and supported combinations. Model selection and provider precedence are +explained once in the [application quickstart](/quickstart#model-providers). + +## Continue a Session + +A Session keeps the conversation and its fixed execution configuration. Submit a +follow-up after the previous Turn settles: + +```python +client.beta.agents.sessions.events.create( + session.id, + events=[{ + "type": "agent.session.input.message", + "input": [{ + "role": "user", + "content": [{"type": "input_text", "text": "Summarize the file you created."}], + }], + }], +) +``` + +Read the new Turn and Items using the same observation loop as the quickstart. +Submitting while a Turn is active steers that Turn where supported; it does not +create an independent parallel task. Create a new Session for a different fixed +configuration or workspace. See [message input](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/message-input.md) +for supported content, steering and idempotency rules. + +## Prepare Skills, Plugins and MCP + +Select capabilities when creating the Session, before sending work that needs them. +Runtime installs one snapshot and passes its Skill paths and MCP declarations to the +Harness. Reconnect reuses that snapshot; changing local source files does not alter +an existing Session. + +| Task | Guide | +| --- | --- | +| Upload a Skill source and select a version | [Source Files and Skills](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/source-files.md) | +| Use local Skill/Plugin directories | [Local capability directories](/self-hosted-execution#local-capability-directories) | +| Provide initial files, user-directory packages or setup | [Environment Templates](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-templates.md) | +| Declare tools, MCP and function callbacks | [Execution tools](/agents-and-tools) | +| Choose a supported tool policy | [Tool policy](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/tool-policy.md) | + +System dependencies must already exist on the host or in its image/template. +See the [Runtime installation guide](/self-hosted-native) for dependencies and +platform support. Missing preparation fails before execution; do not retry a task +as a new Session to work around an unresolved preparation error. + +## Read results and files + +Turn state tells you whether execution is active, completed, failed or cancelled. +Items hold the recorded conversation and tool results; live events describe changes +as they happen. A disconnected stream is not a completion receipt. The +[history and events contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/history-events-usage.md) explains +pagination, event delivery and Usage. + +| Resource | Use it for | Reference | +| --- | --- | --- | +| Environment Files | Inspect or transfer workspace contents | [Environment Files](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/environment-files.md) | +| Artifacts | Retrieve captured outputs associated with execution | [Artifacts and public resources](/sessions) | +| Source Files | Upload input used by a Skill or other declared resource | [Source Files](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/source-files.md) | + +These resources have different lifetimes. Deleting a Session does not erase files +from a user-owned machine. Reclaiming managed compute is a separate Environment +operation; stopping an executor does not imply reclamation. + +## Cancel and recover + +To cancel current work: + +```python +client.beta.agents.sessions.events.create( + session.id, + events=[{"type": "agent.session.input.cancel"}], +) +``` + +Read the Turn until its terminal state is recorded. HTTP acceptance alone does not +prove native processes have stopped. A cancellation or delivery error must be +interpreted using the returned state and the +[error contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/core-errors.md). + +After a lost response or connection, first retrieve the existing Session, Turns +and Items. Do not assume work was never submitted and send it again. For self-hosted +execution, restart the same installation with its retained workspace and native +history; follow the [operator lifecycle](/self-hosted-native#add-harnesses-and-operate-the-installation). +A new Session captures a new configuration, not the history of a deleted one. + +## Diagnose a failure + +1. Check the Session, latest Turn and required actions in the API or Web. +2. Confirm that the Environment is connected and its selected Harness is available. +3. Check the model/provider and capability combination against + [supported execution](/agents-and-tools). +4. Follow [operations and troubleshooting](/troubleshooting) for service + logs, credentials and node readiness. Runtime observations may be stale; the + [diagnostics contract](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/contracts/agents-api/session-diagnostics.md) identifies + which observations are available. + +Administrative actions use a different credential from application work. Keep the +[API namespace reference](/public-api) at hand when diagnosing authentication errors. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/5dc7c587607cfa1ef02608cefb1bc9f54eda09fa/docs/user-guide.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 0124fe977..c9bcd57b0 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,61 +1,56 @@ { - "revision": "f36ac3592bfe9f77a2172680aa52b14c6f525062", + "revision": "5dc7c587607cfa1ef02608cefb1bc9f54eda09fa", "sources": { - "docs/getting-started/README.md": "e4ca3bcbfdcbf3832c1d9e0525f98083ca11a4e42148f9f45090bbe1bc0f2b5c", - "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", + "docs/getting-started/README.md": "cdf8046c616574102a0ffc7644ac0fe82e63eb1906e6c9f1f6b635810e223757", + "docs/design-principles.md": "a33acd6e7bc80105a5c934d0be214c0cff59103e96c098f5b09179c86156c01d", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", - "docs/getting-started/install.md": "706a1b05315e25211606bee7cc63923511268721fe7de88d5ec262938156796f", - "docs/configuration.md": "e911757a03fbce4b803a284c8bb40bdb7000ca2f70e27938706dcdcd74bc7f9a", + "docs/getting-started/install.md": "e2043e0abdd7fc77bbad8d7fe3e17310421a94497c3b73236e653d525c8d34c1", + "docs/configuration.md": "0c15f6253233f4766cae5dda4d33a120c7c53a4770353a86db4890e2a8e7e078", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", - "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", + "docs/getting-started/quickstart.md": "238b4de1137edb8c2998b38f4525fea345a19fd4de7a455aa9c4c9e4fc3c9b36", "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", - "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", - "contracts/agents-api/environments.md": "89ffc903ee44d00ee87c16950202a3ca4c8345160780dcdd4ed2a71b25b1c2dc", - "docs/getting-started/nodes.md": "aa95d21137c37ae1d4a2c0ac0b8f797738882400430e58bef58b17dbd75ed4cc", - "docs/getting-started/self-hosted.md": "3f1b402cf28bb320ba414eaf36110e9df4d792b81da0456a3c42d469357d8d99", + "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", + "contracts/agents-api/environments.md": "4c4b9423b22919d6aa6bd98d96422e1a8d610d3442d4cf7731a575bd31fd43f0", + "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", + "docs/getting-started/self-hosted.md": "99d8851399bde2310196ac89dae1c365bba144421dbb33153094d30072de1783", + "docs/self-hosted-native.md": "e69819c3201109c8be5413716853eafa22d9d84b57aac780a4d335149b6a926b", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", - "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", + "docs/api/web-management.md": "d5f982922320069be5017cd524095d86eee0d66fd0d1cf97d1cdd97544d0dce5", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", - "docs/getting-started/operations.md": "438f8e679e6484d8effb6f4216b08c6472d5cbd50cbd05f5fef4d8648ae147a9", - "apps/docs/scripts/guides.json": "3d4bd123cb9246ca6c15055cee9a927b18eec506944c81f9f0cc4c85ee28e620" + "docs/getting-started/operations.md": "f39d7eaf403ed92c6a1f37c732a19195a389784f78799b3c391654f7d394ac2e", + "docs/user-guide.md": "a601c7f9df02c3bfe9c4ca3a43b91ab5721f3f8e904fccd116418dbdc1665a53", + "docs/development.md": "3bc7b30a663aaca9cc556c4702884a7855481aadc1598191f3ade599168a123d", + "contracts/agents-api/harness-onboarding.md": "e8c9af9f16fed43e3bb420ed567f1c04461b5ab81cd40d2885cdf8ab205efde3", + "docs/runtime-protocol.md": "6baa332705743e34b8565613d743d1b30099d8af78159b06ad921ffa7e164a9f", + "docs/sandbox-provider.md": "dd8c2c2b2e2acf4d6ae564f56ffaf5685d74a84fa7a7a15b4cad851015ee36ab", + "apps/docs/scripts/guides.json": "d9383a63c26b66225a99def12f9184fcf4725e88519c1bf6b5fac8aac7a0bd30" }, "outputs": { - "content/docs/index.mdx": "75339c35292bb1f3c6b4313d717b82686bee8caa265bb575fcecbd0ba33bb780", - "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "2c2516963cccafed4f66083201a9f84e8d12c4d7f4406ce06179bc26616ea019", - "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "8dd05250255fdb39a4aee6ffa51a16379fbd19d2761f510e1ad9cc3a193f750c", - "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "a3ec8393f0b3260fa1e8d6f6c6921f0807c6eed247a92e69f91119c4382c4dc2", - "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "ec84259c766872eada11c1b0d9d9e3ec19acd8c867c6048cc0890a32b82d65f0", - "content/docs/configure.zh.mdx": "a0c3a1df3e9f75e672e1d0a8c68c82c24ac36a8479d10a951de441960d07927f", - "content/docs/bootstrap-projects-keys.mdx": "fc2da0aa255278281d8a57c664d7d30e5a458add4e58402386e0374f23973da4", - "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "4ebc8ac16df66042e816ff51cf6672d8a85f44e119211efba584025666dce845", - "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "12d4fdbe6291c7d74af591369ac1edd2eca2940b93d156612497cc52b933740d", - "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "b8cc50792e579cbf66bcc597685b745d8721266970b75e928d8edee2ce11b08b", - "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "8dba8c2a94ef5e95fde6f53e7af3cc481e4d3c5db71815372f01ddd1bf729316", - "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "0fce7c8806d532d64eff6319bba7d0a8e44da46f888d224644b3ab5c23428c41", - "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "244d97e41fbbc3ee9d6905d055454c07f139659a2b39de81941867ddc37d5d20", - "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "34877624962b24c22e8371ebd2e2f83bbf5a0d004c74d5d9b5db57f5133b692c", - "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", + "content/docs/index.mdx": "e07cc34f8e86006de4ac90a00d33e1dda7ee5875c6049e8acaeb16ab2dfe3cb6", + "content/docs/concepts.mdx": "09f7b6ed7b70d6ce96ab100692353104c5ff242fe654a5c9d88d20f2abfaa279", + "content/docs/execution-model.mdx": "69e765e5b60db55b8673bb0684a89f2d36f62ac0e504f0653dff5e28ddcee0bd", + "content/docs/install.mdx": "8b9f30b8917fcca6e7b621b1c25dfc3dfe37f97fc429111ad9e3f8f139149fa3", + "content/docs/configure.mdx": "2212c28179c6444f65d3537635400313e253b28b0b15e0271d877fbb61f7ee75", + "content/docs/bootstrap-projects-keys.mdx": "d5de83c63c5f9f981baea75ad1713319f3eee03a4d4e3bf1ce5d81d09fac6b01", + "content/docs/quickstart.mdx": "a3855dd496279984d627bc02ba343d83ce946e0c396a51fda0c3bb6405e9d92d", + "content/docs/public-api.mdx": "70f5e49b098ddc0e5b8d81890af4f804d0f7adabf1d1414ff2f86ed6d756c3f3", + "content/docs/agents-and-tools.mdx": "2693ed4ebe6bd6cc7be6f5a416b51443c99c001d7824c92ef10641d76b3b7d2c", + "content/docs/sessions.mdx": "698449f4c607de8c283678aa1b39426d2395c784c3d2bfbef46e8386b1f859c6", + "content/docs/environments-and-files.mdx": "9f70aa1a12f1dc458949e08a921cb306329d5434533ad66520b1d076ad76f248", + "content/docs/hosted-providers.mdx": "ba9f2acbdf0268c1d0bf161ed979d32696abada7ec48fb0e3790b9fce5d9910d", + "content/docs/self-hosted-execution.mdx": "ae88b1474fbc59aa9a70bbf7a73c884a8c67f1b4891eedac833f79a7e251665a", + "content/docs/self-hosted-native.mdx": "2b3efdc508ca123d02d3720e164b4f0b07c730fc9188f5d8b705659f95850dba", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "cf0e6a1f9dd690ffe74c5d83111bb18df7eebe287848497c01257692ab3edc6a", - "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "22331a4aa1c720711861d93db7d207868bf815ccc32b900d07b3d48ef077afd9", - "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "c5e2963a4dd7abddc60869a3ecd3b6b32a97c2ba1ae28b88b7453136656c1201", - "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "cd81feacd1236603423a08ba941dd6bf5538da1a982ee73c171dfb49617d2e76", - "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" + "content/docs/console.mdx": "3a9fedc4bd4b039df6a6c0ff0d4b06d24e082c037d0f504638cc6df68aca3883", + "content/docs/admin-api.mdx": "d95d350f06299b0fd8e8a79d544b3683b6896f51696c7b86c21e9d764fef927f", + "content/docs/observability.mdx": "f4f4f7c01edfd0604aab58e750fc5025b2e58bf979aaf6858af1f1af7e954c2b", + "content/docs/troubleshooting.mdx": "a952fdd4764bd71c88f959c2a5193ec74d7b419f123a8ecde496b313b1e9a05c", + "content/docs/user-guide.mdx": "ac216186391d3177e0d347c64f453d1715008b786ce733640e23af2cfc42327c", + "content/docs/development.mdx": "a580c5cde082d2b31be8f6f9e0ecde845fd2fe79c8b2afbf80bfbaf4293f2709", + "content/docs/harness-onboarding.mdx": "b7c573cf30848eacc190f9ce8ffa28a3152cb7c0a583c2694cdaa045471301ee", + "content/docs/runtime-protocol.mdx": "1a382b1c6da30c2060f26bbdfb1cda3a65c98bd64f7b84889dd312b81173a5be", + "content/docs/sandbox-provider.mdx": "f358d4913365b0e3f8743327760fe408bbf29b760fd36b2aff2ad06473fb8190" } } diff --git a/apps/docs/lib/i18n.ts b/apps/docs/lib/i18n.ts deleted file mode 100644 index 6ec2bbf10..000000000 --- a/apps/docs/lib/i18n.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineI18n } from "fumadocs-core/i18n" - -export const i18n = defineI18n({ - languages: ["en", "zh"], - defaultLanguage: "en", - hideLocale: "default-locale", - parser: "dot", -}) - -export type Lang = (typeof i18n.languages)[number] diff --git a/apps/docs/lib/legacy-redirects.test.ts b/apps/docs/lib/legacy-redirects.test.ts deleted file mode 100644 index b0c6eb243..000000000 --- a/apps/docs/lib/legacy-redirects.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict" -import { existsSync } from "node:fs" -import { join } from "node:path" -import test from "node:test" -import { i18n } from "./i18n.ts" -import { LEGACY_TARGETS, legacyTarget } from "./legacy-redirects.ts" - -// Resolved from either the repo root or the app directory, so the suite runs -// whether `node --test` is started from the app or from the repository root. -const contentRoots = [ - join(process.cwd(), "content", "docs"), - join(process.cwd(), "apps", "docs", "content", "docs"), -] - -/** - * A page counts as live only when every declared language has a source file. - * A redirect landing on an untranslated page would hand a Chinese reader an - * English page, which is the failure these redirects exist to prevent. - */ -function isLivePage(target: string): boolean { - const stem = target === "/" ? "index" : target.replace(/^\//, "") - return contentRoots.some((root) => - i18n.languages.every((lang) => - existsSync( - join(root, lang === i18n.defaultLanguage ? `${stem}.mdx` : `${stem}.${lang}.mdx`), - ), - ), - ) -} - -test("every redirect target is a page that exists in every language", () => { - for (const [from, to] of Object.entries(LEGACY_TARGETS)) { - assert.ok(isLivePage(to), `${from} -> ${to} is not a translated page`) - } -}) - -test("no retired slug is still published", () => { - for (const from of Object.keys(LEGACY_TARGETS)) { - assert.ok(!isLivePage(`/${from}`), `${from} is mapped but is still a live page`) - } -}) - -test("legacyTarget resolves with or without surrounding slashes", () => { - assert.equal(legacyTarget("/api-keys"), "/bootstrap-projects-keys") - assert.equal(legacyTarget("api-keys"), "/bootstrap-projects-keys") - assert.equal(legacyTarget("/api-keys/"), "/bootstrap-projects-keys") -}) - -test("legacyTarget leaves live pages and the root to the router", () => { - assert.equal(legacyTarget("/configure"), undefined) - assert.equal(legacyTarget("/public-api"), undefined) - assert.equal(legacyTarget("/environments-and-files"), undefined) - assert.equal(legacyTarget("/"), undefined) - assert.equal(legacyTarget(""), undefined) -}) \ No newline at end of file diff --git a/apps/docs/lib/legacy-redirects.ts b/apps/docs/lib/legacy-redirects.ts deleted file mode 100644 index 2aeb661c9..000000000 --- a/apps/docs/lib/legacy-redirects.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Slugs retired when the manual was rewritten around the administrator-API - * baseline. Keys are the previous slugs without a locale prefix; values are the - * surviving page that now covers the topic, or `/` for a topic that was folded - * into an overview. - * - * `middleware.ts` answers these with a permanent redirect and carries the - * reader's language across, so `/zh/api-keys` lands on - * `/zh/bootstrap-projects-keys` instead of the English page. - * - * `legacy-redirects.test.ts` asserts every target is a page that still exists - * in every language, and that no retired slug is still published — so this map - * cannot silently rot as the manual changes. - */ -export const LEGACY_TARGETS: Record = { - // Folded into the overview page; no single successor page. - "project-map": "/", - "console-tour": "/", - glossary: "/", - "local-development": "/", - "locales-themes": "/", - - // Renamed, or folded into a surviving page. - "execution-flow": "/execution-model", - "install-core": "/install", - "configure-core": "/configure", - "console-admin": "/admin-api", - credentials: "/configure", - "api-keys": "/bootstrap-projects-keys", - connection: "/configure", - verification: "/install", - - // Agent and Session topics. - agents: "/agents-and-tools", - "create-agent": "/agents-and-tools", - "agent-tools": "/agents-and-tools", - vaults: "/agents-and-tools", - "start-session": "/sessions", - conversation: "/sessions", - - // Operations topics. - dashboard: "/observability", - metrics: "/observability", - trace: "/observability", - system: "/hosted-providers", - - // Environment, file and provider topics. - environment: "/environments-and-files", - "environment-templates": "/environments-and-files", - files: "/environments-and-files", - "sandbox-nodes": "/hosted-providers", - "hosted-sandbox-manager": "/hosted-providers", - "connect-daemon": "/self-hosted-execution", - "connect-executor": "/self-hosted-execution", - - // The public Agent API replaced three separate API pages. - "agents-api": "/public-api", - "agents-api-sessions": "/public-api", - "agents-api-errors": "/public-api", -} - -/** - * Resolve a retired path to its successor. `rest` is the path with any locale - * prefix already removed; surrounding slashes are ignored, and a live page or - * the root resolves to `undefined` so the caller can carry on routing. - */ -export function legacyTarget(rest: string): string | undefined { - const key = rest.replace(/^\/+/, "").replace(/\/+$/, "") - return key ? LEGACY_TARGETS[key] : undefined -} \ No newline at end of file diff --git a/apps/docs/lib/locale-link.test.ts b/apps/docs/lib/locale-link.test.ts deleted file mode 100644 index e6f8b599b..000000000 --- a/apps/docs/lib/locale-link.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { i18n } from "./i18n.ts" -import { prefixLocale } from "./locale-link.ts" - -// The rule this file locks down: a *translated* page must never hand the reader -// back to the default language. `[Configure](/configure)` written in -// `configure.zh.mdx` has to render as `/zh/configure`. -test("a translated page keeps prefix-free internal links in its own language", () => { - assert.equal(prefixLocale("/configure", "zh", i18n), "/zh/configure") - assert.equal(prefixLocale("/configure/", "zh", i18n), "/zh/configure/") - assert.equal(prefixLocale("/", "zh", i18n), "/zh") - assert.equal(prefixLocale("/#section", "zh", i18n), "/zh#section") - assert.equal(prefixLocale("/public-api?tab=2", "zh", i18n), "/zh/public-api?tab=2") -}) - -test("the default language stays prefix-free", () => { - assert.equal(prefixLocale("/configure", "en", i18n), "/configure") - assert.equal(prefixLocale("/", "en", i18n), "/") -}) - -test("a link that already carries a locale is left alone", () => { - // Hand-written prefixed links must not become `/zh/zh/...`. - assert.equal(prefixLocale("/zh/configure", "zh", i18n), "/zh/configure") - // A link into another language is deliberate; do not rewrite it. - assert.equal(prefixLocale("/en/configure", "zh", i18n), "/en/configure") - assert.equal(prefixLocale("/zh", "zh", i18n), "/zh") -}) - -test("non-page links are never prefixed", () => { - const untouched = [ - "https://github.com/MiniMax-AI/parsar-core", - "http://127.0.0.1:18092/core/v1/admin/summary", - "mailto:docs@example.com", - "tel:+1234567890", - "//example.com/path", - "#local-anchor", - "./sibling", - "../parent", - "relative/page", - "", - ] - - for (const href of untouched) { - assert.equal(prefixLocale(href, "zh", i18n), href, `${href} must not be rewritten`) - } -}) - -test("a path that merely starts with a locale name is still prefixed", () => { - // `/environment` must not be mistaken for an `/en`-prefixed link, and - // `/zh-tw` is not a locale this site declares. - assert.equal(prefixLocale("/environment", "zh", i18n), "/zh/environment") - assert.equal(prefixLocale("/zh-tw/guide", "zh", i18n), "/zh/zh-tw/guide") -}) - -test("every declared language is recognised as a prefix", () => { - for (const lang of i18n.languages) { - assert.equal(prefixLocale(`/${lang}/configure`, "zh", i18n), `/${lang}/configure`) - } -}) - -test("translating the default language into itself is a no-op", () => { - // Guards the `lang === defaultLanguage` short circuit: if it ever stops - // firing, every English internal link gains an `/en` prefix the router then - // redirects away from. - for (const href of ["/configure", "/", "/a/b", "#x", "https://example.com"]) { - assert.equal(prefixLocale(href, i18n.defaultLanguage, i18n), href) - } -}) \ No newline at end of file diff --git a/apps/docs/lib/locale-link.ts b/apps/docs/lib/locale-link.ts deleted file mode 100644 index 0cc6cee80..000000000 --- a/apps/docs/lib/locale-link.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * The part of the site's i18n configuration that link rewriting depends on. - * - * Passed in rather than imported so this module stays a pure function with no - * runtime dependency, which lets `locale-link.test.ts` load it directly under - * `node --test` without a bundler or path-alias resolution. - */ -export type LocaleLinkConfig = { - languages: readonly string[] - defaultLanguage: string -} - -/** - * Add the active locale prefix to a root-relative link written in MDX, so that - * navigation inside a translated page stays in that language. - * - * Fumadocs' loader keeps the default language prefix-free - * (`hideLocale: "default-locale"`), so `[Configure](/configure)` written in - * `configure.zh.mdx` would otherwise render as ``: the - * reader leaves the Chinese manual and lands on the English page with no - * visible reason. The docs route therefore renders every MDX anchor through - * `LocaleLink`, so pages can be written with locale-free internal links. - * - * Deliberately left untouched: - * - absolute URLs with a scheme (`https:`, `mailto:`, `tel:`, …) - * - protocol-relative URLs (`//host/path`) - * - in-page anchors (`#section`) - * - relative paths (`./x`, `../x`) - * - paths that already carry a declared locale - * - every link on the default language, whose URLs stay prefix-free - */ -export function prefixLocale( - href: string, - lang: string, - { languages, defaultLanguage }: LocaleLinkConfig, -): string { - if (!href) return href - if (lang === defaultLanguage) return href - if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href - if (href.startsWith("//")) return href - if (href.startsWith("#")) return href - if (!href.startsWith("/")) return href - - const first = href.split("/").filter(Boolean)[0] - if (first && languages.includes(first)) return href - - // The root page is served at `/zh`, not `/zh/`, so a link to the root — with - // or without an anchor or query — drops the separator rather than keeping it. - if (href === "/") return `/${lang}` - if (href.startsWith("/#") || href.startsWith("/?")) return `/${lang}${href.slice(1)}` - - return `/${lang}${href}` -} \ No newline at end of file diff --git a/apps/docs/lib/site.ts b/apps/docs/lib/site.ts index a0a0516a0..6632016e5 100644 --- a/apps/docs/lib/site.ts +++ b/apps/docs/lib/site.ts @@ -1,86 +1,6 @@ -import { existsSync } from "node:fs" -import { join } from "node:path" -import { i18n } from "./i18n" -import { source } from "./source" +// Set the published origin before building crawler metadata. +const siteOrigin = (process.env.DOCS_SITE_ORIGIN ?? "http://127.0.0.1:4001").replace(/\/+$/, "") -/** - * Canonical origin of the published manual, used only to build absolute URLs - * for crawler metadata: the per-page canonical link, the hreflang alternates - * and the sitemap. Set `DOCS_SITE_ORIGIN` to the real origin before publishing - * the manual; the default matches the local review server. - */ -export const SITE_ORIGIN = ( - process.env.DOCS_SITE_ORIGIN ?? "http://127.0.0.1:4001" -).replace(/\/+$/, "") - -/** - * Absolute URL of a page from its Fumadocs-relative url (`/configure`, - * `/zh/configure`). The home page arrives as `/`, which would serialize with a - * trailing slash that the route does not serve; strip it so the sitemap entry - * and the page's own canonical link stay byte-identical. - */ export function absoluteDocsUrl(relative: string): string { - return `${SITE_ORIGIN}${relative === "/" ? "" : relative}` -} - -/** Content directories, so the check works from the repo root or from the app. */ -function contentRoots(): string[] { - return [ - join(process.cwd(), "content", "docs"), - join(process.cwd(), "apps", "docs", "content", "docs"), - ] -} - -function pageStem(slugs: string[]): string { - return slugs.length === 0 ? "index" : slugs.join("/") -} - -/** - * Whether a translated source file exists for a page. - * - * `source.getPage(slugs, lang)` answers for the default language when a - * translation is missing, so it cannot tell "translated" from "fell back". - * The file system can. - */ -export function hasLocalizedPage(slugs: string[], lang: string): boolean { - const stem = pageStem(slugs) - const candidates = - lang === i18n.defaultLanguage - ? [`${stem}.mdx`, `${stem}/index.mdx`] - : [`${stem}.${lang}.mdx`, `${stem}/index.${lang}.mdx`] - - return contentRoots().some((root) => - candidates.some((candidate) => existsSync(join(root, candidate))), - ) -} - -/** - * Next.js `metadata.alternates` for a docs page: - * - `canonical` points at the default-language version, so search engines - * consolidate ranking signals on one URL instead of treating the English - * and Chinese pages as duplicates - * - `languages` declares every translation under its hreflang code - * - `x-default` points at the canonical URL for unmatched audiences - * - * A page translated into one language still gets a valid block; only what - * exists is declared. - */ -export function docsAlternates(slugs: string[]): { - canonical: string - languages: Record -} { - const languages: Record = {} - - for (const lang of i18n.languages) { - if (!hasLocalizedPage(slugs, lang)) continue - const page = source.getPage(slugs, lang) - if (!page) continue - languages[lang] = absoluteDocsUrl(page.url) - } - - const canonical = - languages[i18n.defaultLanguage] ?? Object.values(languages)[0] - if (canonical) languages["x-default"] = canonical - - return { canonical: canonical ?? absoluteDocsUrl("/"), languages } + return `${siteOrigin}${relative === "/" ? "" : relative}` } diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index 761a26f9f..9c2f6f15f 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -1,9 +1,7 @@ import { docs } from "@/.source" import { loader } from "fumadocs-core/source" -import { i18n } from "./i18n" export const source = loader({ baseUrl: "/", source: docs.toFumadocsSource(), - i18n, }) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts deleted file mode 100644 index bff1a2a5a..000000000 --- a/apps/docs/middleware.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server" -import { i18n } from "@/lib/i18n" -import { legacyTarget } from "@/lib/legacy-redirects" - -export default function middleware(request: NextRequest) { - const pathname = request.nextUrl.pathname - const segments = pathname.split("/").filter(Boolean) - const pathLocale = i18n.languages.find((locale) => segments[0] === locale) - const rest = `/${segments.slice(pathLocale ? 1 : 0).join("/")}` - - // Retired slugs first, so a reader following a link into the previous manual - // reaches the page that replaced it instead of a 404. Handled here rather than - // in `next.config.mjs` to keep the locale prefix and the redirect in one hop: - // `/en/api-keys` collapses to `/bootstrap-projects-keys` directly, instead of - // first stripping the prefix and then redirecting a second time. - const legacy = legacyTarget(rest) - if (legacy) { - const target = - pathLocale && pathLocale !== i18n.defaultLanguage - ? `/${pathLocale}${legacy === "/" ? "" : legacy}` - : legacy - const url = new URL(target, request.url) - // `new URL(path, base)` drops the base query, which would silently lose - // campaign and referral parameters on the redirect. - url.search = request.nextUrl.search - return NextResponse.redirect(url, 308) - } - - // The default language is served prefix-free, so its canonical URL has to - // reach the `[lang]` route without a redirect the reader can see. - if (!pathLocale) { - const url = new URL(request.url) - url.pathname = `/${i18n.defaultLanguage}${pathname === "/" ? "" : pathname}` - return NextResponse.rewrite(url) - } - - if (pathLocale === i18n.defaultLanguage) { - // An explicit default-language prefix is not the canonical URL, so this is - // permanent: the docs route serves the default language prefix-free. - const stripped = pathname.slice(`/${pathLocale}`.length) - const target = stripped || "/" - const url = new URL(target, request.url) - url.search = request.nextUrl.search - return NextResponse.redirect(url, 308) - } - - return NextResponse.next() -} - -export const config = { - // Locale routing must not touch API routes, Next's own assets, or anything - // served as a file from `public/`. Without the `images/` exclusion the rewrite - // turns `/images/x.png` into `/en/images/x.png`, which matches no route and - // breaks every screenshot in the manual; the extension guard covers the rest, - // including `favicon.ico`, `icon.png`, `sitemap.xml` and `robots.txt`, which - // must stay outside `[lang]` or crawlers get a 404. - // - // The `api/` and `api$` forms are deliberate rather than a bare `api`: a - // prefix match would also exclude `/api-keys`, a retired page this middleware - // has to redirect. Same reasoning for `images/` and `images$`. - matcher: ["/((?!api/|api$|_next/|images/|images$|.*\\.[a-zA-Z0-9]+$).*)", "/"], -} diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 02a51afa6..a1c49cfb3 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -6,9 +6,6 @@ const withMDX = createMDX() /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, - // Keep the listener's origin for local rewrites. Normalizing 127.0.0.1 to - // localhost makes Next proxy the rewrite and re-enter the locale redirect. - skipMiddlewareUrlNormalize: true, outputFileTracingRoot: fileURLToPath(new URL("../../", import.meta.url)), // Keep the Next.js development overlay indicator out of the product UI. devIndicators: false, diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index e27a325ae..8e3300354 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -5879,7 +5879,7 @@ components: - access - allowed_domains type: object - v1.EnvironmentPackages: + v1.EnvironmentPackagesResponse: properties: npm: items: @@ -5923,7 +5923,7 @@ components: - agent.environment.template type: string packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -7050,7 +7050,7 @@ components: network: $ref: '#/components/schemas/v1.EnvironmentNetwork' packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 29898c58f..2df6a5418 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -575,7 +575,7 @@ paths: tags: - Environment Templates post: - 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, system/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. No compute is allocated. Exact hosted error/retry semantics remain unverified. + 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. parameters: - schema: type: string @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. + description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. parameters: - schema: type: string @@ -3991,14 +3991,9 @@ components: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentPackagesInput: properties: @@ -4014,12 +4009,25 @@ components: type: string type: array - type: 'null' + type: object + v1.EnvironmentPackagesResponse: + properties: + npm: + items: + type: string + type: array + python: + items: + type: string + type: array system: - anyOf: - - items: - type: string - type: array - - type: 'null' + items: + type: string + type: array + required: + - npm + - python + - system type: object v1.EnvironmentTemplate: properties: @@ -4046,7 +4054,7 @@ components: - agent.environment.template type: string packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -4879,7 +4887,7 @@ components: network: $ref: '#/components/schemas/v1.EnvironmentNetwork' packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index bf2a7c62a..f3c75b98f 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,14 +1,14 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "a8896e8b67d78ed0608c7cc5636077ec33f4b4895a04bd6cfb8b038afe941e01", - "contracts/agents-api/core.openapi.yaml": "7801fc77259893815fe9cdcb404f045b375711b9b075eab2b2ed8c5dba55e24b", + "contracts/agents-api/openapi.yaml": "03eecf82e377cba718898e1fd02bd9ae3564dbf7ee9598242a23b26a806077f9", + "contracts/agents-api/core.openapi.yaml": "8fe4f7adff61005cd17bb693a4ccc11744ca40ab87f60bb4dd886f093ac35ca7", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, "outputs": { "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", - "content/docs/api-reference/environment-templates.mdx": "c1daeaec159aea40b0aa5ed72b1e77ac1715471431b558d0d89e05bfe9ad8945", - "content/docs/api-reference/sessions.mdx": "cbebb6a1705b486fb228cbe0065df893227132f9b3884180a3871a3e8c78f311", + "content/docs/api-reference/environment-templates.mdx": "68df9276e858e525e15bb88af624fc8adee8bcdcc03a788faaff56b560fd038c", + "content/docs/api-reference/sessions.mdx": "7c12ac70dfdfef86b5d08b9151a7fafdbec6df4848760c81f059b6a331db81a1", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "83d7f56a788d073e0b19f0bc9197f39ec364375b9cc3a7ec69b79b54bc71e3eb", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "84ae8a33717c817950d757990018edd10a4d31d5a343c8172baa099d101c5a1f", + "openapi/public-api.yaml": "9350970fdd9e97fb3d0908750dbb190beaf40ebc9d4c40b104fec3ff376d9c59", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", @@ -40,7 +40,7 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "7079694e447c0783f85aeb3b73f23af68ebb066540c67251bbae34e7e4bd8e62", + "openapi/core-api.yaml": "bbba80991919dafd55411aa7825f004cc1fa0ffaa42c4b7f9475e744dc9e78eb", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", diff --git a/apps/docs/scripts/check-browser.mjs b/apps/docs/scripts/check-browser.mjs index bf1781696..8bd3476ff 100644 --- a/apps/docs/scripts/check-browser.mjs +++ b/apps/docs/scripts/check-browser.mjs @@ -18,7 +18,7 @@ page.on('pageerror', error => failures.push(error.message)) const screenshots = process.env.DOCS_SCREENSHOT_DIR if (screenshots) fs.mkdirSync(screenshots, { recursive: true }) try { - for (const route of ['/', '/install', '/configure', '/console', '/execution-model', '/api-reference/agents', '/api-reference/core/sandbox-manager', '/api-reference/machine/sandbox-node', '/zh/install']) { + for (const route of ['/', '/install', '/configure', '/console', '/execution-model', '/api-reference/agents', '/api-reference/core/sandbox-manager', '/api-reference/machine/sandbox-node', '/harness-onboarding']) { const response = await page.goto(origin + route, { waitUntil: 'networkidle' }) assert.equal(response.status(), 200, route) assert.ok(await page.locator('h1').count(), 'Missing page title: ' + route) @@ -28,7 +28,7 @@ try { assert.equal(await page.locator('input, form, textarea').count(), 0, 'Reference exposes request controls: ' + route) assert.ok((await page.locator('body').innerText()).includes('Authorization'), 'Missing credential documentation: ' + route) } - if (screenshots && ['/', '/console', '/execution-model', '/api-reference/core/sandbox-manager', '/zh/install'].includes(route)) { + if (screenshots && ['/', '/console', '/execution-model', '/api-reference/core/sandbox-manager', '/harness-onboarding'].includes(route)) { await page.screenshot({ path: path.join(screenshots, (route.replaceAll('/', '-') || 'home') + '.png'), fullPage: false }) } } @@ -37,7 +37,7 @@ try { assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), 'Mobile page overflows viewport') assert.deepEqual(failures, [], 'Browser runtime errors') assert.deepEqual(unexpected, [], 'Documentation made external requests') - console.log('Nine desktop routes, both language paths, mobile layout and read-only API controls passed; no external requests.') + console.log('Nine desktop routes, developer navigation, mobile layout and read-only API controls passed; no external requests.') } finally { await context.close() await browser.close() diff --git a/apps/docs/scripts/check-site.mjs b/apps/docs/scripts/check-site.mjs index 310b01a42..eba981358 100644 --- a/apps/docs/scripts/check-site.mjs +++ b/apps/docs/scripts/check-site.mjs @@ -1,20 +1,5 @@ #!/usr/bin/env node -// Smoke-checks every documentation page against a running server. -// -// A page that exists on disk but 404s in the browser is the failure this catches: -// a missing layout, a bad slug, or a locale route that was never generated. -// -// Both locales are probed for every page, including pages that have no translated -// twin. That is deliberate: Fumadocs falls back to the default language for an -// untranslated page, so `/zh/api-reference/files` is a real, reachable URL even -// though only `api-reference/files.mdx` exists on disk. The Chinese manual links -// into those pages freely, so both forms have to resolve. -// -// Not part of `pnpm verify`, because it needs a built site that is already -// serving. Usage, after `pnpm build && pnpm start`: -// -// node scripts/check-site.mjs [origin] # default http://127.0.0.1:4001 - +// Check every English documentation route against a running built site. import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" @@ -35,12 +20,11 @@ function walk(dir) { // content/docs/index.mdx -> / // content/docs/foo.mdx -> /foo -// content/docs/foo.zh.mdx -> /foo (the zh twin of the same route) // content/docs/a/index.mdx -> /a // content/docs/a/b.mdx -> /a/b function slugFor(file) { let rel = path.relative(contentRoot, file).split(path.sep).join("/") - rel = rel.replace(/\.zh\.mdx$/, "").replace(/\.mdx$/, "").replace(/(^|\/)index$/, "") + rel = rel.replace(/\.mdx$/, "").replace(/(^|\/)index$/, "") return rel.replace(/\/$/, "") } @@ -48,7 +32,6 @@ const slugs = [...new Set(walk(contentRoot).map(slugFor))].sort() const routes = [] for (const slug of slugs) { routes.push(slug ? `/${slug}` : "/") - routes.push(slug ? `/zh/${slug}` : "/zh") } const failures = [] @@ -78,4 +61,4 @@ if (failures.length > 0) { for (const line of failures.sort()) console.error(` ${line}`) process.exit(1) } -console.log(`${checked} routes returned 200 at ${origin} (${slugs.length} pages, both locales).`) \ No newline at end of file +console.log(`${checked} routes returned 200 at ${origin} (${slugs.length} pages).`) \ No newline at end of file diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 29cbb97b0..0cc37bb49 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') 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 = 'f36ac3592bfe9f77a2172680aa52b14c6f525062' +const sourceRevision = '5dc7c587607cfa1ef02608cefb1bc9f54eda09fa' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { @@ -44,14 +44,11 @@ for (const guide of guides) { return label + '(' + url + (anchor ? '#' + anchor : '') + ')' }) const en = `---\ntitle: ${JSON.stringify(guide.title)}\ndescription: ${JSON.stringify(guide.description)}\n---\n\n` + (guide.slug === 'execution-model' ? '![Application, administration and machine credential boundaries](/images/architecture.svg)\n\n' : '') + mdx(body.trim()) + `\n\n[Repository source](${sourceURL(guide.source)})\n` - const zh = `---\ntitle: ${JSON.stringify(guide.title + ' · 中文导读')}\ndescription: "当前版本的中文导读;完整操作步骤见英文正文。"\n---\n\n${guide.zh.replaceAll('<', '<')}\n\n此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/${guide.slug === 'index' ? '' : guide.slug})。\n\n[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart)\n` - for (const [suffix, text] of [['', en], ['.zh', zh]]) { - const relative = `content/docs/${guide.slug}${suffix}.mdx` - fs.writeFileSync(path.join(app, relative), text) - record.outputs[relative] = digest(text) - } + const relative = `content/docs/${guide.slug}.mdx` + fs.writeFileSync(path.join(app, relative), en) + record.outputs[relative] = digest(en) record.sources[guide.source] = digest(original) } record.sources['apps/docs/scripts/guides.json'] = digest(fs.readFileSync(path.join(app, 'scripts/guides.json'))) fs.writeFileSync(path.join(app, 'content/guide-sources.json'), JSON.stringify(record, null, 2) + '\n') -console.log('Rendered ' + guides.length + ' current guides and Chinese reading notes.') +console.log('Rendered ' + guides.length + ' current English guides.') diff --git a/apps/docs/scripts/guides.json b/apps/docs/scripts/guides.json index a32d97136..34f91b7d5 100644 --- a/apps/docs/scripts/guides.json +++ b/apps/docs/scripts/guides.json @@ -3,99 +3,91 @@ "slug": "index", "source": "docs/getting-started/README.md", "title": "OpenAgentCore documentation", - "description": "Install Core and Web, create a Project API key, and add execution capacity.", - "zh": "安装 Core 与 Web,用 Core key 登录 Web,创建项目并签发应用 API key,然后添加执行节点。应用使用 /v1;Web 的服务端使用 /core/v1;机器连接使用 /api/v1。" + "description": "Install Core and Web, create a Project API key, and add execution capacity." }, { "slug": "concepts", "source": "docs/design-principles.md", "title": "Concepts and ownership", - "description": "Projects, credentials and the boundary between applications, administration and execution.", - "zh": "Project 拥有应用资源和执行身份。同一项目的 API keys 共享资源;Core key 只用于管理。Core 负责执行事实,Web 不复制执行所有权。" + "description": "Projects, credentials and the boundary between applications, administration and execution." }, { "slug": "execution-model", "source": "docs/web/architecture.md", "title": "Execution and API architecture", - "description": "Applications call the public API; the administrator browser calls Web; Runtime connects to Core.", - "zh": "应用直接用 Project API key 调用 Core 的 /v1。管理员浏览器通过 Web 会话操作管理界面,Web 服务端持有 Core key。节点和 Runtime 用专属凭据直接连接 Core。" + "description": "Applications call the public API; the administrator browser calls Web; Runtime connects to Core." }, { "slug": "install", "source": "docs/getting-started/install.md", "title": "Install Core and Web", - "description": "Current distribution, host prerequisites, HTTPS, first sign-in and installation options.", - "zh": "安装器默认在 ~/.oac/core 安装 Core、Web 和专用 PostgreSQL,初始没有执行节点。先确定公网 HTTPS 地址,按当前版本的校验步骤下载并安装,再用 Core key 登录 Web。 只支持全新安装与同一版本修复;不支持旧版本原地升级或历史转换。保留旧安装和数据,在新的空目录安装当前版本。" + "description": "Current distribution, host prerequisites, HTTPS, first sign-in and installation options." }, { "slug": "configure", "source": "docs/configuration.md", "title": "Configuration reference", - "description": "The operator config.json, generated files and database-owned runtime settings.", - "zh": "安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。" + "description": "The operator config.json, generated files and database-owned runtime settings." }, { "slug": "bootstrap-projects-keys", "source": "docs/web/core-connection.md", "title": "Sign in and issue application keys", - "description": "Keep the Core key on the management side and issue Project API keys through Web.", - "zh": "使用 Core key 登录 Web。应用开发者在项目中获取 Project API key,设置 OPENAI_BASE_URL 与 OPENAI_API_KEY。Core key 不能用来调用 /v1,应用 key 也不能调用管理或机器接口。" + "description": "Keep the Core key on the management side and issue Project API keys through Web." }, { "slug": "quickstart", "source": "docs/getting-started/quickstart.md", "title": "Call the Agent API", - "description": "Use the pinned SDK, a Project API key and an explicit model provider to run a Session.", - "zh": "应用设置 OPENAI_BASE_URL=https://core.example.com/v1 和 OPENAI_API_KEY=,使用文档固定的 SDK 版本。先配置执行后端和模型,再创建 Session 并读取 Turn 结果。" + "description": "Use the pinned SDK, a Project API key and an explicit model provider to run a Session." }, { "slug": "public-api", "source": "docs/api/README.md", "title": "API namespaces and credentials", - "description": "The public application API, private administration API and machine connection interface.", - "zh": "/v1 使用 Project API key;/core/v1 使用 Core key,由 Web 服务端或管理员脚本调用;/api/v1 只接受对应的节点、注册、daemon 或 executor 凭据。这三类凭据不能互换。" + "description": "The public application API, private administration API and machine connection interface." }, { "slug": "agents-and-tools", "source": "contracts/agents-api/execution-tools.md", "title": "Agents, tools and harnesses", - "description": "Native harness tools, shared declarations and the limits of execution support.", - "zh": "保存 Agent 只保存配置,不启动执行。工具声明由所选原生 harness 执行;Codex、Claude Code 与 MiniMax Code 的能力和限制不同。x_agents_core 保留原有扩展字段名称。" + "description": "Native harness tools, shared declarations and the limits of execution support." }, { "slug": "sessions", "source": "docs/api/public-agent-api.md", "title": "Sessions and public resources", - "description": "Agents, Sessions, Turns, events and resources in the pinned public contract.", - "zh": "Session 是持久对话上下文,绑定一个 Environment。输入事件启动或引导 Turn。Session、Turn 和 Environment 的状态各有含义,不应互相替代;具体字段以公共参考为准。" + "description": "Agents, Sessions, Turns, events and resources in the pinned public contract." }, { "slug": "environments-and-files", "source": "contracts/agents-api/environments.md", "title": "Environments and files", - "description": "Execution placement, Environment resources and workspace ownership.", - "zh": "Environment 与 Session 一起创建。none、self_hosted、openai_hosted 是协议中的执行位置名称;openai_hosted 在这里表示 Core 管理的计算资源。文件和工作区仍由对应资源及执行所有者管理。" + "description": "Execution placement, Environment resources and workspace ownership." }, { "slug": "hosted-providers", "source": "docs/getting-started/nodes.md", "title": "Nodes and hosted providers", - "description": "Add and remove Docker or microsandbox nodes; diagnose connection and readiness.", - "zh": "在 Web 的 Nodes 页面添加节点,并在目标主机执行经过校验的命令。sudo 模式使用 oac-node 系统账户;无 sudo 模式使用用户服务。节点程序不支持通过 --update 升级版本;保留旧节点状态和 Runtime 数据,另行添加新节点。当前 Runtime generation 操作不属于节点程序版本升级。" + "description": "Add and remove Docker or microsandbox nodes; diagnose connection and readiness." }, { "slug": "self-hosted-execution", "source": "docs/getting-started/self-hosted.md", "title": "Self-hosted executors", - "description": "Connect a user-owned Runtime to its Environment with a restricted executor credential.", - "zh": "self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。" + "description": "Connect a user-owned Runtime to its Environment with a restricted executor credential." + }, + { + "slug": "self-hosted-native", + "source": "docs/self-hosted-native.md", + "title": "Native daemon installation", + "description": "Install and operate a self-hosted daemon on Linux, macOS or Windows." }, { "slug": "console", "source": "docs/web/README.md", "title": "Administrator console", "description": "Monitor Core, inspect resources and manage deployment settings through Web.", - "zh": "Web 是管理员控制台,提供 Monitor、Resources 和 Platform 操作。浏览器使用管理会话;Core key 作为上游凭据保留在 Web 服务端。应用 API 的执行请求不经过 Web。", "images": { "docs/web/images/overview.png": "apps/web/public/onboarding/monitor-en.webp" } @@ -104,21 +96,48 @@ "slug": "admin-api", "source": "docs/api/web-management.md", "title": "Core administration API", - "description": "Management operations and their server-side credential boundary.", - "zh": "管理员脚本通过 Core 的回环端口调用 /core/v1,并使用 Core key。公网入口的 /core/v1 由 Web 管理会话处理;不能把它当成应用 API 或机器连接的代理。" + "description": "Management operations and their server-side credential boundary." }, { "slug": "observability", "source": "contracts/agents-api/runtime-observability-api.md", "title": "Runtime observations", - "description": "Current Runtime observations, recorded history and their availability limits.", - "zh": "当前 Runtime 观测与已保存历史是不同的数据。离线或陈旧数据不代表当前健康状态;查看来源、时间和可用性,执行事实仍以 Core 为准。" + "description": "Current Runtime observations, recorded history and their availability limits." }, { "slug": "troubleshooting", "source": "docs/getting-started/operations.md", "title": "Operations and troubleshooting", - "description": "oac lifecycle commands, backups, key rotation, current-version repair and diagnostics.", - "zh": "使用 oac status 查看安装状态,oac start 和 oac stop 管理服务,oac apply 应用 config.json。备份必须同时保留数据库、配置和密钥。只支持全新安装和同一版本修复,不支持原地升级、降级或历史转换;保留旧安装和数据,在新的空目录重新安装。安装、修复和修改状态的 oac 命令共用 .oac.lock;遇到占用应等待当前命令结束,不要删除或替换锁文件。" + "description": "oac lifecycle commands, backups, key rotation, current-version repair and diagnostics." + }, + { + "slug": "user-guide", + "source": "docs/user-guide.md", + "title": "Use OpenAgentCore", + "description": "Sessions, capabilities, files, cancellation and recovery." + }, + { + "slug": "development", + "source": "docs/development.md", + "title": "Develop OpenAgentCore", + "description": "Set up a checkout and choose the right extension boundary." + }, + { + "slug": "harness-onboarding", + "source": "contracts/agents-api/harness-onboarding.md", + "title": "Add a Harness", + "description": "Implement and validate a native Harness adapter." + }, + { + "slug": "runtime-protocol", + "source": "docs/runtime-protocol.md", + "title": "Core\u2013Runtime protocol", + "description": "Runtime lifecycle, messages, receipts and recovery." + }, + { + "slug": "sandbox-provider", + "source": "docs/sandbox-provider.md", + "title": "Add a Sandbox Provider", + "description": "Environment allocation, bootstrap and reclamation." } ] diff --git a/apps/docs/scripts/verify-docs-facts.mjs b/apps/docs/scripts/verify-docs-facts.mjs index 0265012a0..7f075004b 100644 --- a/apps/docs/scripts/verify-docs-facts.mjs +++ b/apps/docs/scripts/verify-docs-facts.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Guide source drift requires regeneration and a review of the Chinese reading notes. +// Guide copies must match their canonical English sources. import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' @@ -10,26 +10,22 @@ const repo = path.resolve(app, '../..') const record = JSON.parse(fs.readFileSync(path.join(app, 'content/guide-sources.json'))) for (const [root, entries] of [[repo, record.sources], [app, record.outputs]]) { for (const [relative, digest] of Object.entries(entries)) { - assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relative))).digest('hex'), digest, 'Regenerate guides and review translations: ' + relative) + assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relative))).digest('hex'), digest, 'Regenerate guides: ' + relative) } } const source = slug => fs.readFileSync(path.join(app, 'content/docs', slug + '.mdx'), 'utf8') for (const token of ['/v1', '/core/v1', '/api/v1', 'Project API key', 'Core key', 'executor']) assert.ok(source('public-api').includes(token), 'Credential matrix omits ' + token) for (const token of ['config.json', 'oac apply']) assert.ok(source('configure').includes(token), 'Configuration guide omits ' + token) for (const token of ['oac-node', '~/.oac/nodes']) assert.ok(source('hosted-providers').includes(token), 'Node guide omits ' + token) -for (const token of ['oac-selfhost', '~/.oac/self-hosted']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) -// Installation policy must agree in both generated guides and reviewed reading notes. +for (const token of ['oac-daemon install', 'OAC_RUNTIME_HOME', 'Linux, macOS and Windows']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) +// Keep the installation policy visible in the operator guides. for (const [slug, tokens] of [ ['troubleshooting', ['In-place version upgrades, downgrades and historical conversions are not supported.', '.oac.lock']], ['hosted-providers', ['Node program version updates are not supported.', '`--update` refuses']], - ['install.zh', ['同一版本修复', '不支持旧版本原地升级或历史转换']], - ['troubleshooting.zh', ['不支持原地升级、降级或历史转换', '.oac.lock']], - ['hosted-providers.zh', ['不支持通过 --update 升级版本', '保留旧节点状态和 Runtime 数据']], ]) for (const token of tokens) assert.ok(source(slug).includes(token), 'Installation policy drift in ' + slug + ': ' + token) for (const file of fs.readdirSync(path.join(app, 'content/docs')).filter(n => n.endsWith('.mdx'))) { const text = fs.readFileSync(path.join(app, 'content/docs', file), 'utf8') for (const retired of ['/core/v1/admin', 'sandbox-manager.openapi.yaml']) assert.ok(!text.includes(retired), 'Obsolete claim in ' + file + ': ' + retired) } -assert.ok(fs.readFileSync(path.join(app, 'FOLLOW-UP.md'), 'utf8').includes('design 50'), 'Missing deferred feature inventory') assert.deepEqual(fs.readFileSync(path.join(app, 'app/icon.png')), fs.readFileSync(path.join(repo, 'apps/web/public/favicon.png')), 'Docs favicon must match the approved Web asset') console.log('Guide copies, source authority and namespace/configuration facts are current.') diff --git a/apps/docs/scripts/verify-links.mjs b/apps/docs/scripts/verify-links.mjs index bcef7a532..9cae1c306 100644 --- a/apps/docs/scripts/verify-links.mjs +++ b/apps/docs/scripts/verify-links.mjs @@ -1,22 +1,5 @@ #!/usr/bin/env node -// Fails when a page links to a route that does not exist. -// -// The manual is a reading path: install, configure, bootstrap a Project, make a -// request, add capacity. A link that 404s breaks that path silently, because the -// page it lives on still renders. This resolves every root-relative link against -// the page files themselves, so it needs no running server. -// -// `content/docs/api-reference/*` is generated in English only, and fumadocs -// serves the default language for a missing translation, so a Chinese page may -// link to the English route and have it resolve. A link is therefore accepted -// when the target exists in either locale. -// -// Markdown that is not a page - the README - is checked too, against the -// filesystem, because its links are relative file paths. Removing a file it -// points at is otherwise invisible until a reviewer clicks it. -// -// Read-only. Run from apps/docs: node scripts/verify-links.mjs - +// Validate documentation routes, anchors and package README file links. import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" @@ -35,28 +18,20 @@ function mdxFiles(dir) { return out } -// index.mdx is the directory itself; index.zh.mdx is its Chinese counterpart. function routeOf(file) { - const rel = path.relative(contentRoot, file).replace(/\.mdx$/, "") - const parts = rel.split(path.sep) - let name = parts.pop() - const isZh = name.endsWith(".zh") - if (isZh) name = name.slice(0, -3) - const slug = name === "index" ? parts.join("/") : [...parts, name].join("/") - const base = slug ? `/${slug}` : "/" - return isZh ? (base === "/" ? "/zh" : `/zh${base}`) : base + const rel = path.relative(contentRoot, file).split(path.sep).join("/") + .replace(/\.mdx$/, "").replace(/(^|\/)index$/, "").replace(/\/$/, "") + return rel ? `/${rel}` : "/" } const files = mdxFiles(contentRoot) -const routes = new Set(files.map(routeOf)) function resolves(target) { const [rawPath, anchor] = target.split("#") - const clean = rawPath.split("?")[0].replace(/^\/en(?=\/|$)/, "") || "/" + const clean = rawPath.split("?")[0] || "/" if (clean.startsWith("/images/")) return fs.existsSync(path.join(appRoot, "public", clean)) const route = clean.length > 1 ? clean.replace(/\/+$/, "") : clean - const bare = route === "/zh" ? "/" : route.startsWith("/zh/") ? route.slice(3) : route - const targetFile = files.find(file => routeOf(file) === bare) + const targetFile = files.find(file => routeOf(file) === route) if (!targetFile) return false if (!anchor) return true const text = fs.readFileSync(targetFile, "utf8") diff --git a/apps/docs/scripts/verify-prerender.mjs b/apps/docs/scripts/verify-prerender.mjs index 17f53b6cc..d9f715144 100644 --- a/apps/docs/scripts/verify-prerender.mjs +++ b/apps/docs/scripts/verify-prerender.mjs @@ -1,20 +1,5 @@ #!/usr/bin/env node -// Asserts that `next build` actually wrote a prerendered page for every route. -// -// This guards a regression that is invisible in the build log. A single -// request-scoped API anywhere in the `[lang]/[[...slug]]` render tree — in the -// page, in a layout, or in the `not-found` boundary — makes Next render every -// docs page per request. The build summary still prints `● (SSG)` and still -// lists all the paths, but no `.html`/`.rsc` files are emitted and every -// response comes back `Cache-Control: no-store`. -// -// The cost lands hardest on the generated API reference: those pages render a -// whole OpenAPI surface, so a navigation that should take 30 ms took 2–3 s. -// -// Usage, after `pnpm build`: -// -// node scripts/verify-prerender.mjs - +// Require static output for every guide and keep API pages read-only. import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" @@ -41,22 +26,17 @@ function walk(dir) { function slugFor(file) { let rel = path.relative(contentRoot, file).split(path.sep).join("/") - rel = rel.replace(/\.zh\.mdx$/, "").replace(/\.mdx$/, "").replace(/(^|\/)index$/, "") + rel = rel.replace(/\.mdx$/, "").replace(/(^|\/)index$/, "") return rel.replace(/\/$/, "") } -// Every page is generated in both locales: an untranslated page falls back to -// the default language, so `/zh/` is a real route either way. const slugs = [...new Set(walk(contentRoot).map(slugFor))].sort() -const locales = ["en", "zh"] const missing = [] -for (const locale of locales) { - for (const slug of slugs) { - // `.../en.html` for the locale root, `.../en/.html` otherwise. +for (const slug of slugs) { const file = slug - ? path.join(appRoot, locale, `${slug}.html`) - : path.join(appRoot, `${locale}.html`) + ? path.join(appRoot, `${slug}.html`) + : path.join(appRoot, "index.html") if (!fs.existsSync(file)) missing.push(path.relative(root, file)) else if (slug.startsWith("api-reference/")) { const html = fs.readFileSync(file, "utf8") @@ -64,10 +44,9 @@ for (const locale of locales) { throw new Error("API reference must not collect credentials or send requests: " + file) } } - } } -const expected = slugs.length * locales.length +const expected = slugs.length if (missing.length > 0) { console.error( `\n${missing.length} of ${expected} routes were not prerendered. ` + @@ -77,8 +56,8 @@ if (missing.length > 0) { if (missing.length > 12) console.error(` … and ${missing.length - 12} more`) console.error( "\nLook for a request-scoped API (headers, cookies, searchParams, " + - "`dynamic = \"force-dynamic\"`) in app/[lang]/**.", + "`dynamic = \"force-dynamic\"`) in app/**.", ) process.exit(1) } -console.log(`${expected} routes prerendered (${slugs.length} pages, ${locales.length} locales).`) +console.log(`${expected} documentation routes prerendered.`) diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask.go b/apps/parsar-daemon/internal/agent/claudecode/ask.go index aee5e9387..36faf314b 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask.go +++ b/apps/parsar-daemon/internal/agent/claudecode/ask.go @@ -10,71 +10,22 @@ import ( ) // askUserQuestionToolName matches Claude Code's built-in tool name. -// We intercept it because it expects a tool_result the human composes, -// and Claude Code's stdin loop has no other way to deliver one. const askUserQuestionToolName = "AskUserQuestion" -// pendingAskTable maps daemon-minted ask_<8hex> ids to the originating -// Claude Code tool_use id and the structured question payload. The -// daemon keeps both directions so SubmitPromptForUserChoice can write -// a matching tool_result back into the agent's stdin, and so a future -// cancel path (Session.Cancel mid-ask) can clean up by ask id. -// -// AskUserQuestion is a normal tool_use frame on the claude side, not a -// control_request — so the table sits beside pendingTable rather than -// extending it. Same code shape, different keying. +// pendingAskTable maps daemon-minted ask IDs to control requests and their +// question snapshots. Take lets only one answer or timeout consume an ask. type pendingAskTable struct { - mu sync.Mutex - byAskID map[string]pendingAskEntry - byToolID map[string]string + mu sync.Mutex + byAskID map[string]pendingAskEntry } type pendingAskEntry struct { - // ToolUseID is set when AskUserQuestion arrives via the - // assistant→tool_use path. Empty when it arrived via control_request. - ToolUseID string - - // CCRequestID is set when AskUserQuestion arrives via the - // control_request path (claude-code under --permission-prompt-tool - // stdio wraps the tool in a can_use_tool permission check). Empty - // when it arrived via tool_use. SubmitPromptForUserChoice picks the - // reply shape based on which one is set: tool_result for tool_use, - // control_response for control_request. CCRequestID string - - // Questions snapshots the question list this ask was raised for so - // the writeback can echo header→answer back to the model in the - // same shape Claude's built-in handler emits. Length >= 1. - Questions []proto.PromptForUserChoiceQuestion -} - -// reverseKey returns whichever id was used to populate byToolID so -// Take / Delete can drop the reverse mapping without caring which -// path recorded the entry. -func (e pendingAskEntry) reverseKey() string { - if e.ToolUseID != "" { - return e.ToolUseID - } - return e.CCRequestID + Questions []proto.PromptForUserChoiceQuestion } func newPendingAskTable() *pendingAskTable { - return &pendingAskTable{ - byAskID: make(map[string]pendingAskEntry), - byToolID: make(map[string]string), - } -} - -// Record links a freshly minted ask id to the originating tool_use id -// and the question snapshot. Used by the assistant tool_use path. -func (p *pendingAskTable) Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) { - if askID == "" || toolUseID == "" { - return - } - p.mu.Lock() - defer p.mu.Unlock() - p.byAskID[askID] = pendingAskEntry{ToolUseID: toolUseID, Questions: questions} - p.byToolID[toolUseID] = askID + return &pendingAskTable{byAskID: make(map[string]pendingAskEntry)} } // RecordControl links a freshly minted ask id to the originating CC @@ -87,17 +38,12 @@ func (p *pendingAskTable) RecordControl(askID, ccRequestID string, questions []p p.mu.Lock() defer p.mu.Unlock() p.byAskID[askID] = pendingAskEntry{CCRequestID: ccRequestID, Questions: questions} - // Re-use byToolID as a generic reverse map: keyed by either - // tool_use_id or cc_request_id depending on path. The two id - // namespaces don't collide (tool_use ids look like toolu_..., cc - // ones are UUIDs). - p.byToolID[ccRequestID] = askID } // Take atomically reads + deletes the entry recorded for askID. Used by // SubmitPromptForUserChoice so two near-simultaneous callers (timer- // fired cancel racing a server-delivered answer) can't both pass and -// each write a tool_result. The loser sees ok=false and returns +// each write a control_response. The loser sees ok=false and returns // ErrUnknownAsk instead. // // Trade-off vs Resolve+Delete: if the subsequent stdin write fails, the @@ -116,101 +62,12 @@ func (p *pendingAskTable) Take(askID string) (pendingAskEntry, bool) { return pendingAskEntry{}, false } delete(p.byAskID, askID) - if key := e.reverseKey(); key != "" { - delete(p.byToolID, key) - } return e, true } -// Peek returns the entry without removing it. Reserved for diagnostic / -// test paths that want to inspect pending state without consuming it. -func (p *pendingAskTable) Peek(askID string) (pendingAskEntry, bool) { - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byAskID[askID] - return e, ok -} - -// LookupByToolUse reverses the mapping. Reserved for the (currently -// unimplemented) "claude internally cancels its own tool" path. -func (p *pendingAskTable) LookupByToolUse(toolUseID string) (string, bool) { - if toolUseID == "" { - return "", false - } - p.mu.Lock() - defer p.mu.Unlock() - askID, ok := p.byToolID[toolUseID] - return askID, ok -} - -// Delete removes both directions for askID. -func (p *pendingAskTable) Delete(askID string) { - if askID == "" { - return - } - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byAskID[askID] - if !ok { - return - } - delete(p.byAskID, askID) - if key := e.reverseKey(); key != "" { - delete(p.byToolID, key) - } -} - -// Len reports the number of outstanding ask requests. -func (p *pendingAskTable) Len() int { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.byAskID) -} - -// interceptAskUserQuestion handled the tool_use path historically. It's -// retained as exported test scaffolding (legacy unit tests still cover -// the case-by-case payload parsing) — translateAssistant no longer -// calls it. See parser.go's tool_use branch comment. -// -// Returns ok=false (no envelope, fall through to a normal TypeToolCall) -// when askPending / askMint are missing, the input shape doesn't fit, -// or the tool_use id is empty. -func (t *translator) interceptAskUserQuestion(toolUseID string, input map[string]any) (proto.Envelope, bool) { - if t.askPending == nil || t.askMint == nil { - return proto.Envelope{}, false - } - if toolUseID == "" { - return proto.Envelope{}, false - } - questions, ok := parseAskUserQuestionInput(input) - if !ok { - return proto.Envelope{}, false - } - - askID := t.askMint() - t.askPending.Record(askID, toolUseID, questions) - - env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, t.runID, proto.PromptForUserChoicePayload{ - AskID: askID, - Questions: questions, - ToolUseID: toolUseID, - }) - if err != nil { - return proto.Envelope{}, false - } - return env, true -} - -// interceptAskUserQuestionFromControlRequest is the control_request- -// path twin. Under --permission-prompt-tool stdio, claude-code wraps -// AskUserQuestion as a can_use_tool permission check instead of -// emitting a normal tool_use frame, so the interception entry point is -// different and the writeback shape (control_response) differs from -// the tool_use path (tool_result). -// -// ccRequestID is the SDK's request_id we'll echo back in the -// control_response. ToolUseID stays empty in the envelope payload — -// there's no tool_use_id available on this path. +// interceptAskUserQuestionFromControlRequest handles the can_use_tool check +// emitted under --permission-prompt-tool stdio. The assistant tool_use copy +// remains a normal tool event; only this request creates an ask. func (t *translator) interceptAskUserQuestionFromControlRequest(ccRequestID string, input map[string]any) (proto.Envelope, bool) { if t.askPending == nil || t.askMint == nil { return proto.Envelope{}, false @@ -306,65 +163,9 @@ func parseAskUserQuestionInput(input map[string]any) ([]proto.PromptForUserChoic return out, true } -// buildAskUserToolResult turns the human's decision into the NDJSON -// frame the daemon writes back to claude's stdin. The shape is a -// stock Claude Code "user message" carrying a tool_result block — -// claude's SDK then closes the loop on the original tool_use as if -// the local handler had returned the result. -// -// Body shape (one line, no trailing comma): -// -// {"type":"user","message":{"content":[ -// {"type":"tool_result","tool_use_id":"", -// "content":[{"type":"text","text":""}], -// "is_error":false} -// ]}} -// -// For a normal answer we encode {"questions":[{"header":..,"answer":..}]} -// — same shape Claude's built-in AskUserQuestion handler emits — so the -// model treats the daemon-mediated path identically to the local one. -// -// For Cancelled answers (timeout, operator stop) we send a plain -// sentence with is_error=false. is_error=true would invite the model -// to retry the same AskUserQuestion call right away, which is exactly -// the deadlock we're trying to avoid. -func buildAskUserToolResult(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - text := formatAskUserResultText(entry, decision) - body, err := json.Marshal(map[string]any{ - "type": "user", - "message": map[string]any{ - "content": []map[string]any{{ - "type": "tool_result", - "tool_use_id": entry.ToolUseID, - "content": []map[string]any{{ - "type": "text", - "text": text, - }}, - "is_error": false, - }}, - }, - }) - if err != nil { - return nil, err - } - return append(body, '\n'), nil -} - -// buildAskUserControlResponse is the control_request-path twin of -// buildAskUserToolResult. Used when claude-code's -// --permission-prompt-tool stdio mode wrapped AskUserQuestion in a -// can_use_tool permission check — the daemon has to respond on the -// control_response channel, not via a user message. -// -// We deny the "permission" (claude won't actually invoke its own -// AskUserQuestion local handler) but pass the human's answer through -// the message field. The SDK surfaces this message to the model as -// the tool_result, closing the loop the same way as the user-message -// path. Body shape: -// -// {"type":"control_response","response":{ -// "subtype":"success","request_id":"", -// "response":{"behavior":"deny","message":""}}} +// buildAskUserControlResponse answers the native permission check. Denying +// execution skips Claude's local AskUserQuestion handler; the SDK supplies the +// message to the model as the tool result, including cancellation instructions. func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { text := formatAskUserResultText(entry, decision) body, err := json.Marshal(map[string]any{ @@ -384,9 +185,7 @@ func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptFor return append(body, '\n'), nil } -// formatAskUserResultText is the shared text-formatting helper, split -// out so tests can lock the wire shape without re-marshalling the -// whole envelope. +// formatAskUserResultText formats answers or cancellation instructions for Claude. func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) string { if decision.Cancelled { reason := strings.TrimSpace(decision.Reason) @@ -403,74 +202,55 @@ func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUser // Multi-question path: stable QuestionID is authoritative. Positional // pairing remains only as a compatibility fallback for older peers; // Header is never a key because duplicate or blank headers are valid. - if len(entry.Questions) > 0 { - out := make([]map[string]any, 0, len(entry.Questions)) - anyAnswer := false - // Single-question + legacy Answers slice with multiple entries - // = the multi-select case the old callback shape used. Join - // them with the same "、" we render to the human so the model - // sees one merged answer string for that question. - if len(entry.Questions) == 1 && len(decision.QuestionAnswers) == 0 && len(decision.Answers) > 1 { - merged := strings.Join(decision.Answers, "、") - return mustMarshalAskQuestions([]map[string]any{{ - "header": entry.Questions[0].Header, - "answer": merged, - }}) - } - for i, q := range entry.Questions { - answer := "" - if i < len(decision.QuestionAnswers) { - answer = decision.QuestionAnswers[i].Answer - if len(decision.QuestionAnswers[i].Answers) > 0 { - answer = strings.Join(decision.QuestionAnswers[i].Answers, "、") - } + out := make([]map[string]any, 0, len(entry.Questions)) + anyAnswer := false + // Single-question + legacy Answers slice with multiple entries + // = the multi-select case the old callback shape used. Join + // them with the same "、" we render to the human so the model + // sees one merged answer string for that question. + if len(entry.Questions) == 1 && len(decision.QuestionAnswers) == 0 && len(decision.Answers) > 1 { + merged := strings.Join(decision.Answers, "、") + return mustMarshalAskQuestions([]map[string]any{{ + "header": entry.Questions[0].Header, + "answer": merged, + }}) + } + for i, q := range entry.Questions { + answer := "" + if i < len(decision.QuestionAnswers) { + answer = decision.QuestionAnswers[i].Answer + if len(decision.QuestionAnswers[i].Answers) > 0 { + answer = strings.Join(decision.QuestionAnswers[i].Answers, "、") } - for _, candidate := range decision.QuestionAnswers { - if q.ID != "" && candidate.QuestionID == q.ID { - answer = candidate.Answer - if len(candidate.Answers) > 0 { - answer = strings.Join(candidate.Answers, "、") - } - break + } + for _, candidate := range decision.QuestionAnswers { + if q.ID != "" && candidate.QuestionID == q.ID { + answer = candidate.Answer + if len(candidate.Answers) > 0 { + answer = strings.Join(candidate.Answers, "、") } + break } - // Legacy callback path: a single-question slot answered via - // the flat Answers slice. Multi-question slots always populate - // QuestionAnswers, so this branch is no-op for them. - if answer == "" && len(decision.Answers) > i { - answer = decision.Answers[i] - } - if answer != "" { - anyAnswer = true - } - out = append(out, map[string]any{ - "header": q.Header, - "answer": answer, - }) } - if !anyAnswer { - // Treat as cancel; the operator effectively chose nothing. - return "The user did not choose any option. Stop follow-up actions and wait for further instructions from the user." + // Legacy callback path: a single-question slot answered via + // the flat Answers slice. Multi-question slots always populate + // QuestionAnswers, so this branch is no-op for them. + if answer == "" && len(decision.Answers) > i { + answer = decision.Answers[i] + } + if answer != "" { + anyAnswer = true } - return mustMarshalAskQuestions(out) + out = append(out, map[string]any{ + "header": q.Header, + "answer": answer, + }) } - - // Legacy fallback: pendingAskEntry has no Questions snapshot - // (e.g. test scaffold that didn't pass one). Re-emit whatever - // Answers carries as a single-question payload so the model still - // receives a parseable result. - answers := decision.Answers - if len(answers) == 0 { + if !anyAnswer { + // Treat as cancel; the operator effectively chose nothing. return "The user did not choose any option. Stop follow-up actions and wait for further instructions from the user." } - answer := answers[0] - if len(answers) > 1 { - answer = strings.Join(answers, "、") - } - return mustMarshalAskQuestions([]map[string]any{{ - "header": "", - "answer": answer, - }}) + return mustMarshalAskQuestions(out) } func mustMarshalAskQuestions(qs []map[string]any) string { diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go index f3388644d..a2334ab93 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go @@ -112,51 +112,28 @@ func TestTranslateAskUserQuestionMultiQuestionIntercepted(t *testing.T) { } } -// TestTranslateAskUserQuestionWithoutAskHookFallsThrough covers the -// legacy translator path (no askPending wired): even AskUserQuestion -// produces a regular TypeToolCall so older callers that don't care -// about the ask flow keep working. -func TestTranslateAskUserQuestionWithoutAskHookFallsThrough(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_a", pending, counterMinter()) - - line := []byte(`{"type":"assistant","message":{"content":[ - {"type":"tool_use","id":"toolu_y","name":"AskUserQuestion","input":{ - "questions":[{"header":"h","question":"?","options":[{"label":"a"}]}] - }} - ]}}`) - - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeToolCall { - t.Fatalf("expected TypeToolCall fall-through, got %#v", out.Envelopes) - } -} - // TestPendingAskTableTakeIsAtomic locks in the Take contract: // concurrent Take(askID) callers must see exactly one ok=true. This is // the contract SubmitPromptForUserChoice relies on to make sure a // timer-fired cancel and a server-delivered answer can't both write a -// tool_result back into claude's stdin. +// control_response back into claude's stdin. func TestPendingAskTableTakeIsAtomic(t *testing.T) { tbl := claudecode.NewPendingAskTableForTest() headerQs := []proto.PromptForUserChoiceQuestion{{Header: "header text", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} emptyQs := []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} - tbl.Record("ask_1", "toolu_aaa", headerQs) - tbl.Record("ask_2", "toolu_bbb", emptyQs) + tbl.RecordControl("ask_1", "cc_aaa", headerQs) + tbl.RecordControl("ask_2", "cc_bbb", emptyQs) if tbl.Len() != 2 { t.Fatalf("Len = %d, want 2", tbl.Len()) } - // First Take consumes the entry and reverse mapping. + // First Take consumes the entry. e, ok := tbl.Take("ask_1") if !ok { t.Fatalf("Take(ask_1) ok=false") } - if e.ToolUseID != "toolu_aaa" || len(e.Questions) != 1 || e.Questions[0].Header != "header text" { + if e.CCRequestID != "cc_aaa" || len(e.Questions) != 1 || e.Questions[0].Header != "header text" { t.Errorf("entry mismatch: %+v", e) } if _, ok := tbl.Take("ask_1"); ok { @@ -168,10 +145,6 @@ func TestPendingAskTableTakeIsAtomic(t *testing.T) { if tbl.Len() != 1 { t.Errorf("Len = %d, want 1", tbl.Len()) } - - // Empty / unknown is a no-op, not a panic. - tbl.Record("", "x", emptyQs) - tbl.Delete("nope") } // TestPendingAskTableTakeRace ensures two goroutines calling Take on @@ -182,7 +155,7 @@ func TestPendingAskTableTakeRace(t *testing.T) { qs := []proto.PromptForUserChoiceQuestion{{Header: "h", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} for i := range 200 { tbl := claudecode.NewPendingAskTableForTest() - tbl.Record("ask_x", "toolu_x", qs) + tbl.RecordControl("ask_x", "cc_x", qs) var wg sync.WaitGroup var winners int32 @@ -205,107 +178,33 @@ func TestPendingAskTableTakeRace(t *testing.T) { } } -// TestPendingAskTableRejectsEmptyKeys verifies the defence against -// half-built calls polluting the table. -func TestPendingAskTableRejectsEmptyKeys(t *testing.T) { - qs := []proto.PromptForUserChoiceQuestion{{Header: "h", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} - tbl := claudecode.NewPendingAskTableForTest() - tbl.Record("", "toolu", qs) - tbl.Record("ask_1", "", qs) - if tbl.Len() != 0 { - t.Errorf("Len = %d, want 0 (both records must be rejected)", tbl.Len()) - } -} - -// askUserResult is the JSON shape we expect SubmitPromptForUserChoice -// to write back into claude's stdin. -type askUserResult struct { - Type string `json:"type"` - Message struct { - Content []struct { - Type string `json:"type"` - ToolUseID string `json:"tool_use_id"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - IsError bool `json:"is_error"` - } `json:"content"` - } `json:"message"` -} - -func decodeAskResult(t *testing.T, raw []byte) askUserResult { - t.Helper() - raw = []byte(strings.TrimSpace(string(raw))) - var v askUserResult - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("decode ask result: %v\nraw=%s", err, raw) - } - if v.Type != "user" { - t.Errorf("Type = %q, want user", v.Type) - } - if len(v.Message.Content) != 1 { - t.Fatalf("Content len = %d, want 1", len(v.Message.Content)) - } - if v.Message.Content[0].Type != "tool_result" { - t.Errorf("Content[0].Type = %q, want tool_result", v.Message.Content[0].Type) - } - return v -} - -// TestBuildAskUserToolResultSingleSelect locks in the single-answer -// wire shape — what we send to claude's stdin must be a valid -// tool_result with the JSON {"questions":[{header, answer}]} body. -func TestBuildAskUserToolResultSingleSelect(t *testing.T) { - body, err := claudecode.BuildAskUserToolResultForTest( - claudecode.PendingAskEntry{ToolUseID: "toolu_abc", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Confirm delete"}}}, - proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Confirm delete"}}, - ) - if err != nil { - t.Fatalf("buildAskUserToolResult: %v", err) - } - v := decodeAskResult(t, body) - if v.Message.Content[0].ToolUseID != "toolu_abc" { - t.Errorf("ToolUseID = %q, want toolu_abc", v.Message.Content[0].ToolUseID) - } - if v.Message.Content[0].IsError { - t.Errorf("IsError = true; success answer must use is_error=false") - } - if !strings.Contains(v.Message.Content[0].Content[0].Text, `"answer":"Confirm delete"`) { - t.Errorf("answer not encoded: %s", v.Message.Content[0].Content[0].Text) - } - if !strings.Contains(v.Message.Content[0].Content[0].Text, `"header":"Confirm delete"`) { - t.Errorf("header not echoed: %s", v.Message.Content[0].Content[0].Text) - } -} - -// TestBuildAskUserToolResultMultiSelect ensures multiple answers join +// TestBuildAskUserControlResponseMultiSelect ensures multiple answers join // into a single human-friendly answer string. -func TestBuildAskUserToolResultMultiSelect(t *testing.T) { - body, err := claudecode.BuildAskUserToolResultForTest( - claudecode.PendingAskEntry{ToolUseID: "toolu_m", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Pick lens"}}}, +func TestBuildAskUserControlResponseMultiSelect(t *testing.T) { + body, err := claudecode.BuildAskUserControlResponseForTest( + claudecode.PendingAskEntry{CCRequestID: "cc_m", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Pick lens"}}}, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Safety", "Performance"}}, ) if err != nil { - t.Fatalf("buildAskUserToolResult: %v", err) + t.Fatalf("buildAskUserControlResponse: %v", err) } - v := decodeAskResult(t, body) - if !strings.Contains(v.Message.Content[0].Content[0].Text, `"answer":"Safety、Performance"`) { - t.Errorf("multi-select join failed: %s", v.Message.Content[0].Content[0].Text) + v := decodeAskControlResponse(t, body) + if !strings.Contains(v.Response.Response.Message, `"answer":"Safety、Performance"`) { + t.Errorf("multi-select join failed: %s", v.Response.Response.Message) } } -// TestBuildAskUserToolResultMultiQuestionPositional locks in the +// TestBuildAskUserControlResponseMultiQuestionPositional locks in the // positional pairing contract: when two questions share the same Header // (or both are blank — claude-code treats `header` as optional), each // question still gets its own answer back. A previous header-keyed map -// approach collapsed duplicates and fed the model the wrong tool_result. -func TestBuildAskUserToolResultMultiQuestionPositional(t *testing.T) { +// approach collapsed duplicates and fed the model the wrong answer. +func TestBuildAskUserControlResponseMultiQuestionPositional(t *testing.T) { // Two questions with IDENTICAL headers — the realistic shape when the // model omits header entirely and both fall back to "". - body, err := claudecode.BuildAskUserToolResultForTest( + body, err := claudecode.BuildAskUserControlResponseForTest( claudecode.PendingAskEntry{ - ToolUseID: "toolu_pos", + CCRequestID: "cc_pos", Questions: []proto.PromptForUserChoiceQuestion{ {Header: "", Question: "q1"}, {Header: "", Question: "q2"}, @@ -319,10 +218,10 @@ func TestBuildAskUserToolResultMultiQuestionPositional(t *testing.T) { }, ) if err != nil { - t.Fatalf("buildAskUserToolResult: %v", err) + t.Fatalf("buildAskUserControlResponse: %v", err) } - v := decodeAskResult(t, body) - text := v.Message.Content[0].Content[0].Text + v := decodeAskControlResponse(t, body) + text := v.Response.Response.Message // Both answers must round-trip; the bug we're locking in against was // "both questions end up with B1" because map["":B1] overwrote "":A1. if !strings.Contains(text, `"answer":"A1"`) { @@ -337,10 +236,10 @@ func TestBuildAskUserToolResultMultiQuestionPositional(t *testing.T) { } } -func TestBuildAskUserToolResultMatchesStableQuestionIDs(t *testing.T) { - body, err := claudecode.BuildAskUserToolResultForTest( +func TestBuildAskUserControlResponseMatchesStableQuestionIDs(t *testing.T) { + body, err := claudecode.BuildAskUserControlResponseForTest( claudecode.PendingAskEntry{ - ToolUseID: "toolu_ids", + CCRequestID: "cc_ids", Questions: []proto.PromptForUserChoiceQuestion{ {ID: "environment", Header: "Environment"}, {ID: "checks", Header: "Checks"}, @@ -354,10 +253,10 @@ func TestBuildAskUserToolResultMatchesStableQuestionIDs(t *testing.T) { }, ) if err != nil { - t.Fatalf("buildAskUserToolResult: %v", err) + t.Fatalf("buildAskUserControlResponse: %v", err) } - v := decodeAskResult(t, body) - text := v.Message.Content[0].Content[0].Text + v := decodeAskControlResponse(t, body) + text := v.Response.Response.Message if !strings.Contains(text, `"answer":"Staging","header":"Environment"`) { t.Fatalf("stable environment answer missing: %s", text) } @@ -366,27 +265,6 @@ func TestBuildAskUserToolResultMatchesStableQuestionIDs(t *testing.T) { } } -// TestBuildAskUserToolResultTimeoutKeepsSuccessShape is the contract -// "don't trigger a retry": even on timeout we set is_error=false and -// rely on the body text to redirect the agent. -func TestBuildAskUserToolResultTimeoutKeepsSuccessShape(t *testing.T) { - body, err := claudecode.BuildAskUserToolResultForTest( - claudecode.PendingAskEntry{ToolUseID: "toolu_t", Questions: []proto.PromptForUserChoiceQuestion{{Header: "?"}}}, - proto.PromptForUserChoiceDecisionPayload{Cancelled: true, Reason: "timeout"}, - ) - if err != nil { - t.Fatalf("buildAskUserToolResult: %v", err) - } - v := decodeAskResult(t, body) - if v.Message.Content[0].IsError { - t.Errorf("IsError = true on timeout; want false to avoid retry loop") - } - text := v.Message.Content[0].Content[0].Text - if !strings.Contains(text, "10 minutes") { - t.Errorf("timeout text doesn't mention the window: %s", text) - } -} - // askControlResponse is the JSON shape we expect from the control_request // writeback path. The SDK reads back {response.subtype, response.request_id, // response.response.behavior, response.response.message}. @@ -471,10 +349,6 @@ func TestTranslateControlRequestAskUserQuestionIntercepted(t *testing.T) { if entry.CCRequestID != "cc_req_xyz" { t.Errorf("entry.CCRequestID = %q, want cc_req_xyz", entry.CCRequestID) } - if entry.ToolUseID != "" { - t.Errorf("entry.ToolUseID = %q, want empty (control_request path)", entry.ToolUseID) - } - // And: a normal control_request (non-AskUserQuestion) still falls // through to the legacy permission path. tr2 := claudecode.NewTranslatorWithAskForTest("run_c2", claudecode.NewPendingTableForTest(), claudecode.NewPendingAskTableForTest(), counterMinter(), askCounterMinter()) @@ -513,6 +387,9 @@ func TestBuildAskUserControlResponseSingleSelect(t *testing.T) { if !strings.Contains(v.Response.Response.Message, `"answer":"Confirm"`) { t.Errorf("answer not encoded in message: %s", v.Response.Response.Message) } + if !strings.Contains(v.Response.Response.Message, `"header":"Confirm delete"`) { + t.Errorf("header not echoed: %s", v.Response.Response.Message) + } } // TestBuildAskUserControlResponseTimeout: timeout must still produce diff --git a/apps/parsar-daemon/internal/agent/claudecode/export_test.go b/apps/parsar-daemon/internal/agent/claudecode/export_test.go index a3cfe05bb..8397994e4 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/export_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/export_test.go @@ -35,9 +35,6 @@ func NewPendingAskTableForTest() *PendingAskTable { return (*PendingAskTable)(newPendingAskTable()) } -func (p *PendingAskTable) Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) { - (*pendingAskTable)(p).Record(askID, toolUseID, questions) -} func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) } @@ -45,10 +42,16 @@ func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { return (*pendingAskTable)(p).Take(askID) } func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { - return (*pendingAskTable)(p).Peek(askID) + 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) } -func (p *PendingAskTable) Delete(askID string) { (*pendingAskTable)(p).Delete(askID) } -func (p *PendingAskTable) Len() int { return (*pendingAskTable)(p).Len() } // NewTranslatorForTest constructs a translator with a deterministic // perm-id minter. askPending and askMint default to nil — covers the @@ -78,13 +81,6 @@ type ( Envelope = proto.Envelope ) -// BuildAskUserToolResultForTest exposes the daemon-side tool_result -// builder so ask_test.go can lock in the JSON shape claude's stdin -// expects. -func BuildAskUserToolResultForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - return buildAskUserToolResult(entry, decision) -} - // 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. diff --git a/apps/parsar-daemon/internal/agent/claudecode/parser.go b/apps/parsar-daemon/internal/agent/claudecode/parser.go index 489841a44..83bee1778 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/parser.go +++ b/apps/parsar-daemon/internal/agent/claudecode/parser.go @@ -19,11 +19,7 @@ type pendingRecorder interface { } // askRecorder is the slice of pendingAskTable the parser needs. -// Mirrors pendingRecorder so tests can substitute a fake. Record covers -// the tool_use path (toolUseID), RecordControl covers the -// control_request path (ccRequestID). type askRecorder interface { - Record(askID, toolUseID string, questions []proto.PromptForUserChoiceQuestion) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) } diff --git a/apps/parsar-daemon/internal/agent/claudecode/session.go b/apps/parsar-daemon/internal/agent/claudecode/session.go index 45063e375..0f2ade015 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/session.go +++ b/apps/parsar-daemon/internal/agent/claudecode/session.go @@ -395,18 +395,7 @@ func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, dec // above; stop is best-effort either way. s.stopAskTimer(askID) - // Pick the reply shape based on which path recorded the entry. - // control_request path (CCRequestID set) needs a control_response - // frame; tool_use path needs a user message with a tool_result block. - var ( - body []byte - buildEr error - ) - if entry.CCRequestID != "" { - body, buildEr = buildAskUserControlResponse(entry, decision) - } else { - body, buildEr = buildAskUserToolResult(entry, decision) - } + body, buildEr := buildAskUserControlResponse(entry, decision) if buildEr != nil { return fmt.Errorf("claudecode: marshal ask reply: %w", buildEr) } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go index b2c432aa8..972e8af08 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go @@ -2,28 +2,51 @@ 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 confirms process exit and output drain, independently of terminal delivery. +// 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 nil + return s.settlementErr default: } - s.process.Cancel() + 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 nil + return s.settlementErr case <-ctx.Done(): select { case <-s.settled: - return nil + return s.settlementErr default: return ctx.Err() } @@ -40,3 +63,5 @@ func (s *session) CancellationOutcome() proto.DonePayload { 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 index 0f94f90ad..b8e64ed3a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go @@ -3,16 +3,15 @@ package claudesdk import ( + "bufio" "bytes" "context" "encoding/json" "errors" "os" - "os/signal" "path/filepath" "reflect" "strings" - "syscall" "testing" "time" @@ -171,12 +170,16 @@ 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, emit func(bridgeEvent)) { +func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode == "cancellation-wait" { - stopped := make(chan os.Signal, 1) - signal.Notify(stopped, syscall.SIGTERM) emit(bridgeEvent{Type: "delta", Delta: "partial"}) - <-stopped + 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)}) @@ -189,6 +192,8 @@ func runCancellationHelper(request startRequest, mode string, emit func(bridgeEv } _, _ = 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" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go index f87ed4969..ebed0792c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go @@ -3,10 +3,10 @@ package claudesdk import ( + "bufio" "context" "encoding/json" "os" - "os/signal" "path/filepath" "strings" "syscall" @@ -119,8 +119,8 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t t.Fatal(err) } } else if mode == "commands-cancel" || mode == "commands-drained" { - if err := s.Cancel(ctx); err != nil { - t.Fatal(err) + if err := s.Cancel(ctx); (err == nil) != (mode == "commands-drained") { + t.Fatalf("command cancellation confirmation: %v", err) } } } @@ -155,13 +155,7 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t } } -func runCommandsHelper(request startRequest, mode string, emit func(bridgeEvent)) { - var stopping chan os.Signal - if mode == "commands-cancel" || mode == "commands-drained" { - stopping = make(chan os.Signal, 1) - signal.Notify(stopping, syscall.SIGTERM) - defer signal.Stop(stopping) - } +func runCommandsHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode != "commands-before-ready" { emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) } @@ -181,7 +175,9 @@ func runCommandsHelper(request startRequest, mode string, emit func(bridgeEvent) } switch mode { case "commands-cancel", "commands-drained": - <-stopping + if !scanner.Scan() { + return + } if mode == "commands-drained" { interrupted := commandEvent("pending", "after", "incomplete", "sleep 30") interrupted.Observation.Output = json.RawMessage(`"observed while draining"`) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go b/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go index 04f76f1b4..1e7b69b27 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/error_classification_test.go @@ -16,7 +16,7 @@ import ( ) func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { - for _, mode := range []string{"valid", "wrong-session", "wrong-result", "success-usage", "cancelled", "unknown", "malformed-code", "after-terminal", "scanner-error", "process-error"} { + 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) @@ -45,8 +45,13 @@ func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { _ = 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" { + 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 { @@ -78,6 +83,11 @@ func runClassifiedFailureHelper(request startRequest, mode string, encode func(b 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) } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go new file mode 100644 index 000000000..beef6fb3e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor.go @@ -0,0 +1,313 @@ +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 start.MCPHTTPServers != nil { + if !info.SupportsHTTPMCP() { + return errors.New("claudesdk: packaged runtime does not support HTTP MCP") + } + for _, server := range *start.MCPHTTPServers { + 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/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go new file mode 100644 index 000000000..631f52793 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -0,0 +1,71 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { + for _, mode := range []string{"unknown_cancel", "queued_cancel", "missing_confirmation", "pending_function", "pending_function_unconfirmed", "pending_input", "confirmed_closed"} { + t.Run(mode, func(t *testing.T) { + config, req := persistentConfig(t, mode) + if mode == "pending_function" || mode == "pending_function_unconfirmed" { + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + } + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + turn, out := consumeExecutorTurn(t, owner, "cancel", "wait") + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("initial output missing") + } + if mode == "pending_function" || mode == "pending_function_unconfirmed" { + if event := <-out; event.Type != proto.TypeFunctionCall { + t.Fatal("function obligation missing") + } + } + var inputReceipt chan error + if mode == "pending_input" { + inputReceipt = make(chan error, 1) + go func() { + inputReceipt <- turn.(*session).SteerWithReceipt(t.Context(), proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}, nil) + }() + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("input write barrier missing") + } + } + cancelErr := turn.Cancel(t.Context()) + for range out { + } + settlement, settlementErr := turn.AwaitSettlement(t.Context()) + confirmed := mode == "confirmed_closed" || mode == "pending_function" + reusable := mode == "pending_function" + if (cancelErr == nil) != confirmed || (settlementErr == nil) != confirmed || settlement.Reusable != reusable { + t.Fatalf("cancel=%v settlement=%+v err=%v", cancelErr, settlement, settlementErr) + } + if inputReceipt != nil { + if err := <-inputReceipt; err == nil { + t.Fatal("missing native input receipt was confirmed") + } + } + // Resource cleanup succeeds independently. It must not erase the immutable + // native outcome used by repeat cancellation and Runtime acknowledgement. + if err := owner.Close(t.Context()); err != nil { + t.Fatal(err) + } + if (turn.Cancel(t.Context()) == nil) != confirmed { + t.Fatal("Close changed cancellation confirmation") + } + if _, err := turn.AwaitSettlement(t.Context()); (err == nil) != confirmed { + t.Fatal("Close changed Turn confirmation") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go new file mode 100644 index 000000000..55768099f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -0,0 +1,54 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "encoding/json" + "os" +) + +func helperTurn(scanner *bufio.Scanner, request *startRequest) (func(bridgeEvent), func()) { + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + if !scanner.Scan() { + os.Exit(4) + } + var start struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input json.RawMessage `json:"input"` + } + if json.Unmarshal(scanner.Bytes(), &start) != nil || start.Type != "turn_start" || start.TurnID == "" || json.Unmarshal(start.Input, &request.Input) != nil { + os.Exit(5) + } + return helperTurnOutput(scanner, start.TurnID) +} + +func helperTurnOutput(scanner *bufio.Scanner, id string) (func(bridgeEvent), func()) { + terminal := false + reusable := true + encode := func(event bridgeEvent) { + event.TurnID = id + _ = json.NewEncoder(os.Stdout).Encode(event) + if event.Type == "result" || event.Type == "error" { + terminal = true + if event.Type == "error" && event.Code != "cancelled" { + reusable = false + } + } + } + encode(bridgeEvent{Type: "turn_started"}) + return encode, func() { + if !terminal { + return + } + reason := "" + if !reusable { + reason = "native_error" + } + confirmed := true + encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, Reusable: &reusable, Reason: reason}) + for scanner.Scan() { + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go new file mode 100644 index 000000000..a2ac5f536 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -0,0 +1,199 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "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/google/uuid" +) + +// Explicit opt-in acceptance; normal test runs never contact a provider. +func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { + entry, keyPath, proof := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_PROOF_DIR") + if entry == "" || keyPath == "" || proof == "" { + t.Skip("explicit installed runtime, private key file and proof directory required") + } + if !filepath.IsAbs(proof) { + t.Fatal("absolute proof directory required") + } + if err := os.MkdirAll(proof, 0700); err != nil { + t.Fatal(err) + } + key, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal("cannot read selected credential file") + } + t.Setenv("OAC_RUNTIME_HOME", proof) + config := Config{Node: os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_NODE"), Entrypoint: entry, StateDir: filepath.Join(proof, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", + "ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k3", "ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k3", + }} + if proxy := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_HTTP_PROXY"); proxy != "" { + config.Env = append(config.Env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy, "http_proxy="+proxy, "https_proxy="+proxy) + } + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) + defer cancel() + readiness, err := CheckRuntime(ctx, config) + if err != nil { + t.Fatal("installed runtime readiness failed", err) + } + type turnEvidence struct { + Run string `json:"run"` + NodePID int `json:"node_pid"` + NativePIDs []int `json:"native_pids"` + FirstTextMS int64 `json:"first_text_ms"` + ElapsedMS int64 `json:"elapsed_ms"` + CancelMS int64 `json:"cancel_ms,omitempty"` + Cancelled bool `json:"cancelled"` + CancellationError string `json:"cancellation_error,omitempty"` + Settlement agent.TurnSettlement `json:"settlement"` + SettlementError string `json:"settlement_error,omitempty"` + Done proto.DonePayload `json:"done"` + Errors []string `json:"errors,omitempty"` + } + evidence := struct { + Runtime RuntimeInfo `json:"runtime"` + PrepareMS int64 `json:"prepare_ms"` + Turns []turnEvidence `json:"turns"` + Recovered bool `json:"recovered_after_cancel"` + Closed bool `json:"closed"` + }{Runtime: readiness} + persist := func() { + raw, _ := json.MarshalIndent(evidence, "", " ") + _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) + } + defer persist() + request := proto.PromptRequestPayload{StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "kimi-k3", "system_prompt": "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."}} + factory := NewExecutorFactory(config) + prepared := time.Now() + owner, err := factory(ctx, request) + if err != nil { + t.Fatal("executor preparation failed", err) + } + evidence.PrepareMS = time.Since(prepared).Milliseconds() + defer func() { + closeCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + evidence.Closed = owner.Close(closeCtx) == nil + persist() + }() + nativeChildren := func(pid int) []int { + raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) + var ids []int + for _, value := range strings.Fields(string(raw)) { + child, _ := strconv.Atoi(value) + args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", child)) + if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { + ids = append(ids, child) + } + } + slices.Sort(ids) + return ids + } + run := func(id, prompt string, cancelOnText bool) turnEvidence { + t.Helper() + started := time.Now() + record := turnEvidence{Run: id, NodePID: owner.(*executor).base.process.Cmd.Process.Pid} + output := make(chan proto.Envelope, 128) + turn, err := owner.StartTurn(ctx, id, proto.TextInput(prompt), output) + if err != nil || turn == nil { + t.Fatal("Turn start failed", err) + } + cancellation := make(chan error, 1) + var cancelAt time.Time + for event := range output { + if event.ID != id { + t.Fatal("output crossed Turn identity") + } + if event.Type == proto.TypeDelta && record.FirstTextMS == 0 { + record.FirstTextMS = time.Since(started).Milliseconds() + record.NativePIDs = nativeChildren(record.NodePID) + if cancelOnText { + record.Cancelled = true + cancelAt = time.Now() + go func() { + cancelCtx, stop := context.WithTimeout(ctx, 30*time.Second) + defer stop() + cancellation <- turn.Cancel(cancelCtx) + }() + } + } + if event.Type == proto.TypeError { + var failure proto.ErrorPayload + _ = event.DecodePayload(&failure) + record.Errors = append(record.Errors, failure.Error) + } + if event.Type == proto.TypeDone { + _ = event.DecodePayload(&record.Done) + } + } + if record.Cancelled { + err := <-cancellation + record.CancelMS = time.Since(cancelAt).Milliseconds() + if err != nil { + record.CancellationError = err.Error() + } + } + record.Settlement, err = turn.AwaitSettlement(ctx) + if err != nil { + record.SettlementError = err.Error() + } + record.ElapsedMS = time.Since(started).Milliseconds() + evidence.Turns = append(evidence.Turns, record) + persist() + t.Logf("turn=%s bridge_pid=%d native_pids=%v reusable=%t first_text_ms=%d total_ms=%d", id, record.NodePID, record.NativePIDs, record.Settlement.Reusable, record.FirstTextMS, record.ElapsedMS) + return record + } + marker := "REUSE-" + uuid.NewString() + first := run("first", "Remember this marker: "+marker+". Reply with exactly the marker.", false) + if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Done.Content, marker) { + t.Fatal("first Turn failed or was not reusable") + } + second := run("second", "What exact marker did I give you? Reply with only that marker.", false) + if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Done.Content, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { + t.Fatal("ordinary Turns did not retain native execution and history") + } + interrupted := run("cancel", "List the numbers 1 through 10000, one number per line, without stopping early.", true) + if !interrupted.Cancelled { + t.Fatal("native output ended before cancellation boundary") + } + if interrupted.SettlementError != "" || !interrupted.Settlement.Reusable { + if interrupted.Settlement.Reason == "" && interrupted.SettlementError == "" { + t.Fatal("invalid executor omitted its reason") + } + if err := owner.Close(ctx); err != nil { + t.Fatal("invalid owner cleanup was not confirmed", err) + } + native, _ := interrupted.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + if native == "" { + native, _ = second.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + } + request.AgentSessionID = native + request.RequireExistingNativeSession = true + owner, err = factory(ctx, request) + if err != nil { + t.Fatal("history recovery failed", err) + } + evidence.Recovered = true + } + continued := run("continued", "What exact marker did I originally give you? Reply with only the marker.", false) + if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Done.Content, marker) { + t.Fatal("history did not continue after cancellation") + } + if !evidence.Recovered && (continued.NodePID != second.NodePID || !slices.Equal(continued.NativePIDs, second.NativePIDs)) { + t.Fatal("reusable cancellation replaced native execution") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go new file mode 100644 index 000000000..39d18f03a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go @@ -0,0 +1,301 @@ +//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/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"` + 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": + // 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, "") + 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: + } +} +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 { + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go new file mode 100644 index 000000000..789a34a90 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go @@ -0,0 +1,242 @@ +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 index b135f8ec5..aaa56e61c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions.go @@ -115,9 +115,10 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio return err } data, err := json.Marshal(struct { - Type string `json:"type"` + Type string `json:"type"` + TurnID string `json:"turn_id"` proto.FunctionResultPayload - }{Type: "function_result", FunctionResultPayload: result}) + }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) if err != nil { return err } @@ -139,7 +140,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio 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.process.Cancel) + stop := context.AfterFunc(ctx, s.invalidate) defer stop() s.writeMu.Lock() if err = ctx.Err(); err == nil { @@ -147,7 +148,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio } s.writeMu.Unlock() if err != nil { - s.process.Cancel() + s.invalidate() return fmt.Errorf("claudesdk: function result transport failed") } select { @@ -158,11 +159,11 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio } } -func (s *session) functionsComplete() bool { +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 { + if !pending.applied && !(cancelled && pending.result == nil) { return false } } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go index cc5c6a71a..09045a612 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go @@ -75,8 +75,8 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { } case proto.TypeDelta: if mode == "functions-cancel" { - if err := running.Cancel(ctx); err != nil { - t.Fatal(err) + if err := running.Cancel(ctx); err == nil { + t.Fatal("unconfirmed function receipts became successful cancellation") } } case proto.TypeError: @@ -127,7 +127,9 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner } if mode == "functions-cancel" { emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) - time.Sleep(time.Hour) + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } return } if mode == "functions-no-receipt" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/installation.go b/apps/parsar-daemon/internal/agent/claudesdk/installation.go new file mode 100644 index 000000000..d4ed22a20 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/installation.go @@ -0,0 +1,23 @@ +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/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index a4e8d1888..8c4e043f5 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -11,14 +11,12 @@ import ( // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared // localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy, staging string) (Config, error) { +func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy) (Config, error) { config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network.Access, AllowedDomains: network.Hosts(), - HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), - ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), staging}, - DependencyPath: "/usr/local/bin:/usr/bin:/bin", + Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), + HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), + ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), } if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go index 51c7ca7b0..819fdac36 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -36,9 +36,10 @@ func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { t.Fatal(err) } config.Workspace.PublicDirectory = alias - if _, _, err := prepare(config, req); err == nil { - t.Fatal("accepted mutable workspace alias") + if _, _, err := prepare(config, req); err != nil { + t.Fatal("same workspace alias rejected", err) } + } func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { @@ -47,9 +48,8 @@ func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { config.Workspace.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} req := workspaceRequest() req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted", AllowedDomains: []string{"api.example.com", "EXAMPLE.COM"}} - start, _, err := prepare(config, req) - if err != nil || !slices.Equal(start.Workspace.AllowedDomains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost exact bound domains", start, err) + if _, _, err := prepare(config, req); err == nil { + t.Fatal("Runtime must not promise inner network isolation") } for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { req.LocalEnvironment.AllowedDomains = domains diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go index 5f48890fc..6cd293b29 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -2,6 +2,7 @@ package claudesdk import ( "encoding/json" + "os" "strings" "testing" @@ -15,19 +16,20 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) req := workspaceRequest() token := "selected-user-token" t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ - {PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, - {PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &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 { + 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] - if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 6 || stdio.Args[4] != "plugins/local" || stdio.Args[5] != "local" { + 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) @@ -42,9 +44,6 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) if entry == reference+"="+token { found = true } - if strings.Contains(entry, "unselected-native-token") { - t.Fatal("inherited native credential") - } } if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { t.Fatal("selected credential or observation declarations missing") @@ -65,7 +64,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { e.MCP[0].Server.URL = "http://example.invalid/mcp" }, } { - environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://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 := prepareEnvironmentMCP(environment); err == nil { t.Fatal("unsupported declaration accepted") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go index cdd683865..36b228698 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go @@ -2,129 +2,66 @@ package claudesdk import ( "context" - "encoding/json" - "fmt" - "slices" - "strings" + "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 - session *session - ready chan struct{} - started chan struct{} - binding *preparedStart - closed bool - failure error + mu sync.Mutex + executor *executor + session *session + binding *preparedStart + closed bool } type preparedStart struct { - runID string - prompt proto.MessageInput - out chan<- proto.Envelope + turn agent.Turn + done chan struct{} } -var _ agent.PreparedCancellation = (*prepared)(nil) - -// NewPreparationFactory retains a trusted workspace process without submitting model input. func NewPreparationFactory(config Config) agent.PreparationFactory { - return func(owner context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if owner == nil { - owner = context.Background() - } - if config.Workspace == nil || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("claudesdk: preparation requires workspace configuration without input or conversation") - } - snapshot := config - snapshot.Env = slices.Clone(config.Env) - workspace := *config.Workspace - workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) - snapshot.Workspace = &workspace - start, env, err := prepareConfiguration(snapshot, req) - if err != nil { - return nil, err - } - start.Type = "prepare" - info, err := CheckRuntime(owner, snapshot) - if err != nil || !info.supportsWorkspacePreparation() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace preparation") + 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") } - if start.OutputFormat != nil && !info.SupportsWorkspaceStructuredOutput() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace structured output") - } - if start.Subagents != nil && !info.SupportsSubagents() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") - } - if start.observeFunctions && !info.supportsWorkspaceCommands() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace command observations") - } - if len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace functions") - } - s, err := launch(owner, snapshot, start, env) - if err != nil { + resource, err := factory(ctx, req) + if resource == nil { return nil, err } - s.reads.supported = slices.Contains(info.Features, "workspace_read") - s.directories.supported = slices.Contains(info.Features, "workspace_directory") - p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} - go s.run(owner, "", start, nil, p) - select { - case <-p.ready: - if owner.Err() == nil { - return p, nil - } - _ = p.Close() - return nil, owner.Err() - case <-s.settled: - return nil, p.failure - } + e := resource.(*executor) + return &prepared{executor: e, session: e.base}, err } } -// Start transfers ownership once; ctx bounds this operation, not the Session lifetime. -func (p *prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } +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, fmt.Errorf("claudesdk: preparation is no longer available") - } - var err error - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil || out == nil { - err = fmt.Errorf("claudesdk: start requires a run identity, prompt and output channel") - } else if ctx.Err() != nil { - err = ctx.Err() - } else if p.session.process.Context().Err() != nil { - err = p.session.process.Context().Err() - } else { - select { - case <-p.session.process.Done(): - err = fmt.Errorf("claudesdk: prepared process has exited") - case <-p.session.settled: - err = fmt.Errorf("claudesdk: preparation has ended") - default: - } + return nil, errors.New("claudesdk: admission is unavailable") } - if err != nil { - p.closed = true - p.mu.Unlock() - _ = p.session.Cancel(context.Background()) - return nil, err + 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 } - p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} - close(p.started) + close(binding.done) p.mu.Unlock() - return p.session, nil + if turn != nil { + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() + } + return turn, err } -// Close settles unused ownership and is inert after transfer. func (p *prepared) Close() error { p.mu.Lock() if p.binding != nil { @@ -133,51 +70,47 @@ func (p *prepared) Close() error { } p.closed = true p.mu.Unlock() - return p.session.Cancel(context.Background()) + return p.executor.Close(context.Background()) } -// Cancel follows the resource across transfer and waits for the existing output settlement. 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() - return p.session.Cancel(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - return p.session.CancellationOutcome() -} - -func (p *prepared) awaitStart(scanner *bridgeOutput, failure error) (*preparedStart, error) { - if failure == nil { - if scanner.Scan() { - var event bridgeEvent - if json.Unmarshal(scanner.Bytes(), &event) != nil { - failure = fmt.Errorf("claudesdk: invalid SDK bridge output") - } else if event.Type == "error" { - failure = bridgeFailure(event.Code) - } else if event.Type != "prepared" { - failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") - } - } else { - failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") + 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 failure != nil { - p.session.process.Cancel() - return nil, failure + if binding.turn == nil { + return p.executor.Close(ctx) } - close(p.ready) - select { - case <-p.started: - case <-p.session.process.Context().Done(): - case <-p.session.process.Done(): + 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 { - return p.binding, nil + if p.binding == nil || p.binding.turn == nil { + return proto.DonePayload{} } - p.closed = true - return nil, fmt.Errorf("claudesdk: preparation ended before start") + return p.binding.turn.CancellationOutcome() } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go index 2c02bd316..556c00786 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -45,12 +45,12 @@ func runPreparationHelper() { features = []string{"workspace_tools", "workspace_prepare"} } if strings.HasPrefix(mode, "structured-") { - features = append(features, "local_runtime_v1", "structured_output") + features = append(features, "local_runtime_v2", "structured_output") if mode == "structured-ready" { features = append(features, "workspace_structured_output") } } - _ = json.NewEncoder(os.Stdout).Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 2, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture", Features: features}) + _ = json.NewEncoder(os.Stdout).Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 3, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture", Features: features}) return } state := os.Getenv("CLAUDE_CONFIG_DIR") @@ -63,7 +63,7 @@ func runPreparationHelper() { _ = os.WriteFile(filepath.Join(state, "prepare.json"), raw, 0o600) var fields map[string]json.RawMessage var request startRequest - if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(raw, &request) != nil || request.Type != "prepare" || fields["input"] != nil || fields["run_id"] != nil || request.Workspace == nil { + if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(raw, &request) != nil || request.Type != "executor_prepare" || fields["input"] != nil || fields["run_id"] != nil || request.Workspace == nil { os.Exit(3) } emit := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } @@ -84,7 +84,7 @@ func runPreparationHelper() { time.Sleep(time.Millisecond) } } - emit(bridgeEvent{Type: "prepared"}) + emit(bridgeEvent{Type: "executor_ready", Protocol: 3}) if strings.HasPrefix(mode, "directory-") { runWorkspaceDirectoryHelper(scanner, state, mode) return @@ -99,15 +99,19 @@ func runPreparationHelper() { raw = append([]byte{}, scanner.Bytes()...) _ = os.WriteFile(filepath.Join(state, "start.json"), raw, 0o600) fields = nil - if json.Unmarshal(raw, &fields) != nil || len(fields) != 2 || string(fields["type"]) != `"start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { + if json.Unmarshal(raw, &fields) != nil || len(fields) != 3 || string(fields["type"]) != `"turn_start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { os.Exit(4) } + var turnID string + _ = json.Unmarshal(fields["turn_id"], &turnID) + emit, finish := helperTurnOutput(scanner, turnID) + defer finish() if mode == "cancellation" { - runCancellationHelper(request, "cancellation-wait", emit) + runCancellationHelper(request, "cancellation-wait", scanner, emit) return } if strings.HasPrefix(mode, "commands") { - runCommandsHelper(request, mode, emit) + runCommandsHelper(request, mode, scanner, emit) return } emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index 610c9154f..4ca35e982 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -68,7 +68,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.ProtectedDirs[0] = "/changed" + config.Workspace.Directory = "/changed" req.AgentOptions["model"] = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) @@ -78,7 +78,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err != nil { t.Fatal(err) } - if s != p.session || s.(*session).process.Cmd.Process.Pid != pid { + 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 { @@ -184,12 +184,13 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { if startErr == nil { t.Fatal("invalid or cancelled Start succeeded") } + err = p.Close() } if err != nil { t.Fatal(err) } select { - case <-p.session.settled: + case <-p.executor.done: case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } @@ -280,7 +281,10 @@ func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { } } if err := p.Cancel(t.Context()); err != nil { - t.Fatal(err) + // 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(): diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go index 45f6b3621..308d45bca 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/provider.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/provider.go @@ -7,7 +7,7 @@ import ( ) // Provider options are transient operator input, never public Agent fields or -// native tool environment. The workspace sandbox removes these variables. +// native process environment. Isolation is the outer environment's responsibility. func providerEnvironment(value any) ([]string, error) { fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") } options, ok := value.(map[string]any) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 878f3ed16..a04e2c0dc 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -74,7 +74,7 @@ func (info RuntimeInfo) supportsWorkspaceCommands() bool { } func (info RuntimeInfo) SupportsLocalRuntime() bool { - return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1") + return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v2") } func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { @@ -132,7 +132,7 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { return RuntimeInfo{}, fmt.Errorf("claudesdk: runtime check failed") } var info RuntimeInfo - if json.Unmarshal(raw, &info) != nil || info.Type != "runtime_ready" || info.Protocol != 2 || + if json.Unmarshal(raw, &info) != nil || info.Type != "runtime_ready" || info.Protocol != 3 || info.Node == "" || info.SDK == "" || info.MCP == "" || info.Native == "" { return RuntimeInfo{}, fmt.Errorf("claudesdk: invalid runtime readiness report") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go index a4e14866c..2e4825cc0 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go @@ -15,7 +15,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const readyReport = `{"type":"runtime_ready","protocol":2,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` +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)"}` func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { root := t.TempDir() @@ -128,11 +128,11 @@ func runReadinessHelper() { os.Exit(5) } } - _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":2`, `"protocol":2,"features":["mcp_http_tools"]`, 1)) + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":3`, `"protocol":3,"features":["mcp_http_tools"]`, 1)) case "malformed": _, _ = fmt.Fprintln(os.Stdout, "not-json") case "wrong-protocol": - _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":2`, `"protocol":1`, 1)) + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":3`, `"protocol":1`, 1)) case "missing-version": _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"mcp":"1.30.0"`, `"mcp":""`, 1)) case "multiple": diff --git a/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go b/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go new file mode 100644 index 000000000..82b07f95e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go @@ -0,0 +1,52 @@ +package claudesdk + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" +) + +type runtimeCheckCache struct { + mu sync.Mutex + stamp string + info RuntimeInfo +} + +// Registration retains the package check. Replacing an installed entrypoint, +// readiness companion, package manifest or Node binary invalidates the result. +func (c *runtimeCheckCache) check(ctx context.Context, config Config) (RuntimeInfo, error) { + node := config.Node + if node == "" { + node = "node" + } + node, err := exec.LookPath(node) + if err != nil { + return RuntimeInfo{}, err + } + root := filepath.Dir(config.Entrypoint) + stamp := "" + for _, path := range []string{node, config.Entrypoint, filepath.Join(root, "runtime_check.js"), filepath.Join(root, "../package.json"), filepath.Join(root, "../runtime-manifest.json")} { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + stamp += path + ":missing;" + continue + } + return RuntimeInfo{}, err + } + stamp += fmt.Sprintf("%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.stamp == stamp && c.info.Protocol == 3 { + return c.info, nil + } + info, err := CheckRuntime(ctx, config) + if err == nil { + c.info, c.stamp = info, stamp + } + return info, err +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go index fe88ea0cd..f8d7a1f82 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" - "strings" "sync" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -14,10 +12,21 @@ import ( ) 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 + writeMu *sync.Mutex functions functionState steering steeringState settled chan struct{} @@ -25,284 +34,51 @@ type session struct { } func NewFactory(config Config) agent.Factory { + prepareExecutor := NewExecutorFactory(config) return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } - if out == nil { - return nil, fmt.Errorf("claudesdk: output channel is required") - } - if config.Workspace != nil { - runID, prompt := req.RunID, req.Input - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil { - return nil, fmt.Errorf("claudesdk: run id and prompt are required") - } - req.RunID, req.Input = "", nil - prepared, err := NewPreparationFactory(config)(ctx, req) - if err != nil { - return nil, err - } - defer prepared.Close() - return prepared.Start(ctx, runID, prompt, out) - } - start, env, err := prepare(config, req) + run, input := req.RunID, req.Input + req.RunID, req.ConversationID, req.Input = "", "", nil + resource, err := prepareExecutor(ctx, req) if err != nil { return nil, err } - if start.MCPHTTPServers != nil || start.Subagents != nil { - info, err := CheckRuntime(ctx, config) - if start.Subagents != nil && (err != nil || !info.SupportsSubagents()) { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") - } - if start.MCPHTTPServers != nil && (err != nil || !info.SupportsHTTPMCP()) { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support HTTP MCP") - } - if start.MCPHTTPServers != nil { - for _, server := range *start.MCPHTTPServers { - if server.Required && !info.SupportsHTTPMCPRequired() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") - } - if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") - } - } - } - } - s, err := launch(ctx, config, start, env) - if err != nil { + turn, err := resource.StartTurn(ctx, run, input, out) + if turn == nil { + _ = resource.Close(context.Background()) return nil, err } - go s.run(ctx, req.RunID, start, out, nil) - return s, nil + // 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"` - 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"` -} + 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"` -func (s *session) run(ctx context.Context, runID string, start startRequest, out chan<- proto.Envelope, prepared *prepared) { - defer func() { - if out != nil { - close(out) - } - }() - defer s.stopFunctions() - defer s.stopSteering() - defer s.stopWorkspaceReads() - defer s.stopWorkspaceDirectories() - emit := func(kind string, payload any) { - event, err := proto.NewEnvelope(kind, runID, payload) - if err != nil { - return - } - // Cancellation must drain native output even if the event consumer stops. - // Terminal publication follows settlement and cannot hold up Cancel. - var stopping <-chan struct{} - if kind != proto.TypeError && kind != proto.TypeDone { - stopping = s.process.Context().Done() - } - // Preserve drained observations when the consumer can accept them immediately. - select { - case out <- event: - return - default: - } - select { - case out <- event: - case <-ctx.Done(): - case <-stopping: - } - } - stderrDone := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, s.process.Stderr); close(stderrDone) }() - var failure error - if err := json.NewEncoder(s.process.Stdin).Encode(start); err != nil { - failure = fmt.Errorf("claudesdk: cannot submit SDK input") - s.process.Cancel() - } - scanner := s.bridgeOutput() - if prepared != nil { - binding, err := prepared.awaitStart(scanner, failure) - if binding == nil { - prepared.failure, _ = s.drain(scanner, stderrDone, err) - close(s.settled) - return - } - runID, out = binding.runID, binding.out - s.writeMu.Lock() - err = json.NewEncoder(s.process.Stdin).Encode(struct { - Type string `json:"type"` - Input proto.MessageInput `json:"input"` - }{Type: "start", Input: binding.prompt}) - s.writeMu.Unlock() - if err != nil { - failure = fmt.Errorf("claudesdk: cannot submit SDK input") - s.process.Cancel() - } - } - var content strings.Builder - var result *bridgeEvent - var classifiedFailure error - var engineCode string - var usage proto.Usage - var usageSession string - var failedResultID string - usageIDs := map[string]bool{} - var sequence uint64 - terminal := false - mcp := mcpState{calls: map[string]proto.ToolObservation{}} - commands := commandState{calls: map[string]proto.ToolObservation{}} - for scanner.Scan() { - var event bridgeEvent - if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || terminal { - failure = fmt.Errorf("claudesdk: invalid SDK bridge output") - s.process.Cancel() - break - } - switch event.Type { - case "command_observation": - if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { - failure = err - s.process.Cancel() - } - case "mcp_observation": - if err := mcp.receive(event, start, emit); err != nil { - failure = err - s.process.Cancel() - } - case "delta": - if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { - failure = fmt.Errorf("claudesdk: invalid message delta identity") - s.process.Cancel() - 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.process.Cancel() - break - } - emit(proto.TypeOutputMessage, message) - case "function_call", "function_applied": - if err := s.receiveFunction(event, start, emit); err != nil { - failure = err - s.process.Cancel() - } - case "input_ready", "input_closed", "input_applied", "input_rejected": - if err := s.receiveInput(event, start); err != nil { - failure = err - s.process.Cancel() - } - 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.process.Cancel() - 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.process.Cancel() - break - } - nextUsage, err := appendNativeUsage(usage, event.Usage) - failure = err - if failure != nil { - s.process.Cancel() - 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() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { - failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") - s.process.Cancel() - } else { - result = &event - } - terminal = true - case "error": - 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.process.Cancel() - } - if failure != nil && !terminal { - break - } - } - var bridgeClean bool - failure, bridgeClean = s.drain(scanner, stderrDone, failure) - if result == nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK result is missing") - } - mcp.close(start, emit) - commands.close(start, emit) - s.stopFunctions() - 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} - // Router completion cleanup calls Cancel while consuming Done. Settle first. - close(s.settled) - if failure != nil { - if failure != classifiedFailure || !bridgeClean { - engineCode = "" - } - emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error(), Code: engineCode}) - } - emit(proto.TypeDone, s.outcome) + 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) { @@ -314,7 +90,7 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if err != nil { return nil, err } - return &session{process: process, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil + 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) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go index 37d64ded3..2ced109bb 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go @@ -101,6 +101,10 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { } 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) @@ -117,18 +121,23 @@ func TestMain(m *testing.M) { } 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 != "start" || *request.Input[0].Content[0].Text != "hello" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { + if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "executor_prepare" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { os.Exit(3) } - encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + encode, finish := helperTurn(scanner, &request) + defer finish() mode := os.Getenv("SDK_HELPER_MODE") if strings.HasPrefix(mode, "cancellation-") { - runCancellationHelper(request, mode, encode) + runCancellationHelper(request, mode, scanner, encode) return } if strings.HasPrefix(mode, "steering-") { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering.go b/apps/parsar-daemon/internal/agent/claudesdk/steering.go index b87b16f1d..c9d2c0eae 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering.go @@ -34,6 +34,11 @@ func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) err // 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() } @@ -42,9 +47,10 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP } 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", InputID: input.InputID, Input: input.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) } @@ -74,7 +80,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP 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.process.Cancel) + stop := context.AfterFunc(ctx, s.invalidate) s.writeMu.Lock() if err = ctx.Err(); err == nil { _, err = s.process.Stdin.Write(append(data, '\n')) @@ -82,7 +88,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP s.writeMu.Unlock() stop() if err != nil { - s.process.Cancel() + s.invalidate() return fmt.Errorf("claudesdk: input transport failed") } if written != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go index 256856efc..7bd7f68d3 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go @@ -147,10 +147,11 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("completed execution accepted input", err) } - select { - case <-s.process.Done(): - default: - t.Fatal("completion preceded process release") + 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) } }) } @@ -186,7 +187,9 @@ func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner os.Exit(3) } if mode == "steering-cancel" { - time.Sleep(time.Hour) + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } return } if mode == "steering-timeout" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go new file mode 100644 index 000000000..23db27770 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go @@ -0,0 +1,34 @@ +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/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 3a58c38b2..07ebad94a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -7,7 +7,6 @@ import ( "strings" "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/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" @@ -16,7 +15,7 @@ import ( // 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, be canonical and be mutually disjoint. +// State directories must already exist. // PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { Directory string @@ -25,23 +24,19 @@ type WorkspaceConfig struct { AllowedDomains []string HomeDir string ScratchDir string - ProtectedDirs []string - DependencyPath string } type workspaceProfile struct { - MCP []environmentMCPServer `json:"mcp,omitempty"` - Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` - ToolEnvironment bool `json:"tool_environment,omitempty"` - SystemPackages bool `json:"system_packages,omitempty"` - Home string `json:"home"` - State string `json:"state"` - Scratch string `json:"scratch"` - ProtectedDirs []string `json:"protected_dirs"` - DependencyPath string `json:"dependency_path"` - EnvNames []string `json:"env_names"` - NetworkAccess string `json:"network_access,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` + 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) { @@ -58,15 +53,16 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if err != nil { return nil, nil, err } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + if req.LocalEnvironment != nil { + profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() + if err != nil { return nil, nil, err } - profile.ToolEnvironment = true - profile.SystemPackages = req.LocalEnvironment.SystemPackages } + if req.LocalEnvironment != nil { profile.Skills = req.LocalEnvironment.Skills + profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot } servers, credentials, err := prepareEnvironmentMCP(req.LocalEnvironment) if err != nil { @@ -83,90 +79,42 @@ func workspaceCwd(w *WorkspaceConfig) string { return w.Directory } -// Workspace Env is a replacement, unlike the existing none profile's overlay. -// Only explicitly selected provider settings reach either readiness or execution. +// 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 || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { - return fail() - } - if (w.NetworkAccess != "" || len(w.AllowedDomains) > 0) && (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Validate() != nil { + if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { return fail() } - if w.PublicDirectory != "" { - actual, err := os.Stat(w.Directory) - alias, aliasErr := os.Stat(w.PublicDirectory) - if !canonicalWorkspaceDir(w.Directory) || !canonicalWorkspaceDir(w.PublicDirectory) || err != nil || aliasErr != nil || !os.SameFile(actual, alias) { + 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() } } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - if config.Entrypoint != filepath.Join(runtimeDir, "dist", "main.js") || !canonicalWorkspaceDir(runtimeDir) { - return fail() - } - // Keep the exact paths used by execution and readiness outside mutable roots. - // A symlinked entrypoint must not select an unchecked sibling companion. - codePaths := []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} - for _, path := range codePaths { - resolved, err := filepath.EvalSymlinks(path) - info, statErr := os.Stat(path) - if err != nil || resolved != path || statErr != nil || !info.Mode().IsRegular() { + for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { + if !canonicalWorkspaceDir(path) { return fail() } } - roots := append([]string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir}, w.ProtectedDirs...) - for i, dir := range roots { - if !canonicalWorkspaceDir(dir) || pathContains(runtimeDir, dir) || pathContains(dir, runtimeDir) { + 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() } - for _, previous := range roots[:i] { - if pathContains(previous, dir) || pathContains(dir, previous) { - return fail() - } - } - for _, executable := range codePaths { - if pathContains(dir, executable) { - return fail() - } - } } - managed, err := paths.Root() - if err != nil { - return fail() - } - managed, err = filepath.EvalSymlinks(managed) - if err != nil || managed == config.StateDir || !pathContains(managed, config.StateDir) { - return fail() - } - if w.DependencyPath == "" { - return fail() - } - var dependencies []string - for _, dir := range filepath.SplitList(w.DependencyPath) { - info, err := os.Stat(dir) - if !workspacePathSyntax(dir) || err != nil || !info.IsDir() { - return fail() - } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil { - return fail() - } - for _, root := range roots { - if pathContains(root, dir) || pathContains(dir, root) || pathContains(root, resolved) || pathContains(resolved, root) { - 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) } - dependencies = append(dependencies, resolved) - } - dependencyPath := strings.Join(dependencies, string(os.PathListSeparator)) - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, - ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Hosts()} - env := []string{"PATH=" + dependencyPath, "HOME=" + w.HomeDir, "TMPDIR=" + w.ScratchDir, - "CLAUDE_CONFIG_DIR=" + config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", - "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} + } + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "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, "=") @@ -195,20 +143,9 @@ func canonicalWorkspaceDir(dir string) bool { if !workspacePathSyntax(dir) { return false } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil || resolved != 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 && dir != string(filepath.Separator) && - !strings.ContainsAny(dir, "*?[]{}():\\") && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 -} - -func pathContains(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) + 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.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go index 14507ab80..4cbf89803 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go @@ -57,6 +57,9 @@ func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxE } func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + if s.owner != nil { + return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) + } read, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) if err != nil { return agent.WorkspaceDirectoryResult{}, err diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go index f31003a9c..bc4822ce4 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -20,14 +20,25 @@ import ( ) 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 == "start" { + 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 } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go index 832b1dcce..d24d0211c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -11,25 +11,26 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func TestWorkspaceLaunchAndReadinessExcludeParentEnvironment(t *testing.T) { +func TestWorkspaceLaunchAndReadinessInheritsUserEnvironment(t *testing.T) { config := workspaceFixture(t) t.Setenv("OAC_TEST_PARENT_SECRET", "must-not-inherit") t.Setenv("ANTHROPIC_API_KEY", "unselected") // An owned process fixture verifies both real subprocess launch paths; it is // not a native sandbox or provider acceptance test. script := `#!/bin/sh -test -z "${OAC_TEST_PARENT_SECRET+x}" || exit 21 +test "$OAC_TEST_PARENT_SECRET" = must-not-inherit || exit 21 test -z "${ANTHROPIC_API_KEY+x}" || exit 22 test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 case "$1" in */runtime_check.js) - printf '%s\n' '{"type":"runtime_ready","protocol":2,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; + printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; *) IFS= read -r request - printf '%s\n' '{"type":"prepared"}' + printf '%s\n' '{"type":"executor_ready","protocol":3}' IFS= read -r request - printf '%s\n' '{"type":"result","session_id":"native","text":"completed"}' ;; + printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native","text":"completed"}' + printf '%s\n' '{"type":"turn_settled","turn_id":"run","confirmed":true,"reusable":true,"reason":""}' ;; esac ` if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { @@ -70,109 +71,3 @@ esac t.Fatal("old packaged bridge started execution") } } - -func TestWorkspaceRejectsMutableRuntimeAliasesBeforeReadiness(t *testing.T) { - for _, name := range []string{"node", "entrypoint", "scratch-entrypoint", "companion", "dependencies"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(config.Workspace.Directory, "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - link, target := "", "" - switch name { - case "node": - link, target = filepath.Join(config.Workspace.Directory, "node"), config.Node - config.Node = link - case "entrypoint", "scratch-entrypoint": - dir := config.Workspace.Directory - if name == "scratch-entrypoint" { - dir = config.Workspace.ScratchDir - } - link, target = filepath.Join(dir, "main.js"), config.Entrypoint - config.Entrypoint = link - if err := os.WriteFile(filepath.Join(dir, "runtime_check.js"), []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "companion": - link, target = filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), filepath.Join(config.Workspace.Directory, "companion.js") - if err := os.Remove(link); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(target, []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "dependencies": - link, target = filepath.Join(config.Workspace.Directory, "deps"), config.Workspace.DependencyPath - config.Workspace.DependencyPath = link - } - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("mutable runtime alias passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid binding launched a credential-bearing child") - } - }) - } -} - -func TestWorkspaceUsesCanonicalDependencyPaths(t *testing.T) { - config := workspaceFixture(t) - canonical := config.Workspace.DependencyPath - alias := filepath.Join(filepath.Dir(config.StateDir), "dependency-alias") - if err := os.Symlink(canonical, alias); err != nil { - t.Fatal(err) - } - config.Workspace.DependencyPath = alias - start, env, err := prepare(config, workspaceRequest()) - if err != nil { - t.Fatal(err) - } - if start.Workspace.DependencyPath != canonical || env[0] != "PATH="+canonical { - t.Fatal("trusted launch retained a mutable dependency alias") - } -} - -func TestWorkspaceRejectsPackagedRuntimeOverlapBeforeReadiness(t *testing.T) { - for _, name := range []string{"workspace", "state", "home", "scratch", "protected", "runtime-root", "runtime-parent", "unexpected-layout"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(filepath.Dir(config.StateDir), "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - dependencies := filepath.Join(runtimeDir, "node_modules") - switch name { - case "workspace": - config.Workspace.Directory = dependencies - case "state": - config.StateDir = dependencies - case "home": - config.Workspace.HomeDir = dependencies - case "scratch": - config.Workspace.ScratchDir = dependencies - case "protected": - config.Workspace.ProtectedDirs = []string{dependencies} - case "runtime-root": - config.Workspace.Directory = runtimeDir - case "runtime-parent": - config.Workspace.Directory = filepath.Dir(runtimeDir) - case "unexpected-layout": - config.Entrypoint = filepath.Join(filepath.Dir(config.Entrypoint), "other.js") - if err := os.WriteFile(config.Entrypoint, nil, 0o700); err != nil { - t.Fatal(err) - } - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("invalid runtime binding passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid runtime binding launched a credential-bearing child") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go index c2a27cc19..c55a0211e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -57,7 +57,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { } config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: scratch, ProtectedDirs: []string{filepath.Dir(placement.KeyFile)}, DependencyPath: placement.DependencyPath}, + ScratchDir: scratch}, Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} if placement.Proxy != "" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go index 9ed4b64c7..5f0a4052d 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go @@ -57,6 +57,9 @@ func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes } 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 diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go index 61209b63b..d9bfa2221 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go @@ -20,13 +20,24 @@ import ( ) func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { + turnID := "" for scanner.Scan() { var req struct { Type, ID, Path string - Max int `json:"max_bytes"` + TurnID string `json:"turn_id"` + Max int `json:"max_bytes"` } _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "start" { + if req.Type == "turn_cancel" { + reusable := false + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) + confirmed := true + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) + return + } + if req.Type == "turn_start" { + turnID = req.TurnID + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) _ = os.WriteFile(filepath.Join(state, "read-started"), []byte("{}"), 0600) continue } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go index a9f7a8ebc..f555fd75b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -52,7 +52,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } } var started map[string]json.RawMessage - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 2 || started["output_format"] != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 3 || started["output_format"] != nil { t.Fatal("Start replaced the prepared configuration", err) } }) @@ -60,7 +60,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } func TestWorkspaceStructuredReadinessRequiresCompleteLocalContract(t *testing.T) { - features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v1", "structured_output", "workspace_structured_output"} + features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v2", "structured_output", "workspace_structured_output"} if !(RuntimeInfo{Features: features}).SupportsWorkspaceStructuredOutput() { t.Fatal("qualified Runtime unavailable") } @@ -73,3 +73,14 @@ func TestWorkspaceStructuredReadinessRequiresCompleteLocalContract(t *testing.T) }) } } + +func TestLocalRuntimeRejectsObsoleteWorkspaceBundle(t *testing.T) { + info := RuntimeInfo{Features: []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v1", "workspace_functions", "structured_output", "workspace_structured_output"}} + if info.SupportsLocalRuntime() || info.SupportsWorkspaceFunctions() || info.SupportsWorkspaceStructuredOutput() { + t.Fatal("obsolete workspace and MCP launcher contract was accepted") + } + info.Features[3] = "local_runtime_v2" + if !info.SupportsLocalRuntime() || !info.SupportsWorkspaceFunctions() || !info.SupportsWorkspaceStructuredOutput() { + t.Fatal("current workspace contract was rejected") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index ef39f675f..04e2fcfa2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -25,7 +25,7 @@ func workspaceFixture(t *testing.T) Config { config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: filepath.Join(root, "scratch"), ProtectedDirs: []string{filepath.Join(root, "secrets")}, DependencyPath: filepath.Join(root, "bin")}} + ScratchDir: filepath.Join(root, "scratch")}} for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { if err := os.WriteFile(name, nil, 0o700); err != nil { t.Fatal(err) @@ -59,8 +59,8 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { name, value, _ := strings.Cut(item, "=") values[name] = value } - if _, ok := values["OAC_TEST_PARENT_SECRET"]; ok { - t.Fatal("inherited parent credential") + if values["OAC_TEST_PARENT_SECRET"] != "parent-only" { + t.Fatal("lost user environment") } if _, ok := values["ANTHROPIC_API_KEY"]; ok { t.Fatal("inherited unselected provider credential") @@ -76,7 +76,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { + for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() @@ -112,15 +112,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { config.Env = append(config.Env, "ANTHROPIC_AUTH_TOKEN=second") case "bad-env": config.Env = append(config.Env, "ANTHROPIC_API_KEY=bad\x00value") - case "path-empty-component": - config.Workspace.DependencyPath += ":" - case "path-workspace": - config.Workspace.DependencyPath = config.Workspace.Directory - case "code-in-workspace": - config.Node = filepath.Join(config.Workspace.Directory, "node") - if err := os.WriteFile(config.Node, nil, 0o700); err != nil { - t.Fatal(err) - } + } if _, _, err := prepare(config, req); err == nil { t.Fatal("invalid binding or request accepted") diff --git a/apps/parsar-daemon/internal/agent/clirunner/process.go b/apps/parsar-daemon/internal/agent/clirunner/process.go index 384c2ae20..e693df7f5 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os/exec" + "runtime" "sync" "syscall" "time" @@ -50,7 +51,7 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = 3 * time.Second } - if opts.OwnProcessGroup { + if opts.OwnProcessGroup || runtime.GOOS == "windows" { return startProcessGroup(opts) } diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go index 8160c61a4..d5763742b 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package clirunner diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go similarity index 91% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go rename to apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go index d1e246aba..8a3cbf162 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || darwin package clirunner @@ -12,7 +12,6 @@ import ( "os/signal" "path/filepath" "strconv" - "strings" "syscall" "testing" "time" @@ -163,25 +162,6 @@ func waitChild(t *testing.T, dir string) int { return 0 } -func waitExited(t *testing.T, pid int) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if os.IsNotExist(err) { - return - } - if err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) > 0 && fields[0] == "Z" { - return - } - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("child %d is still running", pid) -} - func TestOwnedGroupHelper(t *testing.T) { if os.Getenv("GO_WANT_OWNED_GROUP") != "1" { return diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go new file mode 100644 index 000000000..4604a23ef --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go @@ -0,0 +1,191 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// The child cannot execute user code until it belongs to the non-inheritable Job. +// Closing the daemon's last Job handle also kills children after a daemon crash. +func startProcessGroup(opts StartOptions) (*Process, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return nil, err + } + ctx, cancel := context.WithCancel(opts.Parent) + cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append([]string{}, opts.Env...) + } + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED | windows.CREATE_NEW_PROCESS_GROUP} + group := &ownedJob{handle: job} + cmd.Cancel = group.cancel + p := &Process{Cmd: cmd, ctx: ctx, cancel: cancel, done: make(chan struct{}), cancelProcess: group.cancel} + stdout, stdoutWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + return nil, err + } + stderr, stderrWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + stdout.Close() + stdoutWriter.Close() + return nil, err + } + p.Stdout, p.Stderr = stdout, stderr + cmd.Stdout, cmd.Stderr = stdoutWriter, stderrWriter + if opts.NeedStdin { + p.Stdin, err = cmd.StdinPipe() + } + if err == nil { + err = cmd.Start() + } + stdoutWriter.Close() + stderrWriter.Close() + if err == nil { + group.mu.Lock() + if group.cancelled { + err = context.Canceled + } + process, openErr := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid)) + if openErr != nil { + err = openErr + } + if err == nil { + err = windows.AssignProcessToJobObject(job, process) + } + if openErr == nil { + windows.CloseHandle(process) + } + if err == nil { + err = resumeInitialThread(uint32(cmd.Process.Pid)) + } + group.mu.Unlock() + } + if err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + cancel() + _ = group.cancel() + group.finish() + closePipe(p.Stdin) + stdout.Close() + stderr.Close() + return nil, fmt.Errorf("clirunner: owned Windows process: %w", err) + } + var waitErr error + p.waitProcess = func() error { <-p.done; stdout.Close(); stderr.Close(); return waitErr } + go func() { + waitErr = cmd.Wait() + // A successful leader exit cannot leave detached descendants behind. + _ = group.cancel() + if group.finish() { + close(p.done) + } + }() + return p, nil +} + +func resumeInitialThread(pid uint32) error { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer windows.CloseHandle(snapshot) + entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))} + for err = windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) { + if entry.OwnerProcessID != pid { + continue + } + thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return err + } + _, err = windows.ResumeThread(thread) + windows.CloseHandle(thread) + return err + } + return fmt.Errorf("clirunner: suspended process thread unavailable: %w", err) +} + +type ownedJob struct { + mu sync.Mutex + handle windows.Handle + finished bool + cancelled bool + processes []windows.Handle + observationErr error +} + +func (g *ownedJob) cancel() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.finished { + return os.ErrProcessDone + } + if !g.cancelled { + // Hold process handles before termination removes them from the Job list. + g.processes, g.observationErr = jobProcessHandles(g.handle) + } + g.cancelled = true + return windows.TerminateJobObject(g.handle, 1) +} + +// Job accounting can reach zero before the process objects become signaled. +// Keep ownership until both observations confirm cleanup; never wait with mu held. +func (g *ownedJob) finish() bool { + g.mu.Lock() + processes, observationErr := g.processes, g.observationErr + g.mu.Unlock() + if observationErr != nil { + return false + } + for _, process := range processes { + status, err := windows.WaitForSingleObject(process, windows.INFINITE) + if err != nil || status != windows.WAIT_OBJECT_0 { + return false + } + } + type accounting struct { + UserTime, KernelTime, PeriodUserTime, PeriodKernelTime int64 + PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses uint32 + } + for { + var info accounting + err := windows.QueryInformationJobObject(g.handle, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil) + if err == nil && info.ActiveProcesses == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + g.mu.Lock() + defer g.mu.Unlock() + g.finished = true + for _, process := range processes { + windows.CloseHandle(process) + } + windows.CloseHandle(g.handle) + return true +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go new file mode 100644 index 000000000..b2d442b54 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go @@ -0,0 +1,141 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "golang.org/x/sys/windows" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestWindowsOwnedTree(t *testing.T) { + for _, mode := range []string{"cancel", "parent-cancel", "leader-exit"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1"), OwnProcessGroup: true}) + if err != nil { + t.Fatal(err) + } + defer func() { p.Cancel(); _ = p.Wait() }() + go io.Copy(io.Discard, p.Stdout) + go io.Copy(io.Discard, p.Stderr) + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + switch mode { + case "cancel": + p.Cancel() + p.Cancel() + case "parent-cancel": + cancel() + case "leader-exit": + os.WriteFile(filepath.Join(dir, "exit"), nil, 0600) + } + done := make(chan struct{}) + go func() { _ = p.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("owned tree did not settle") + } + if status, err := windows.WaitForSingleObject(handle, 0); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("descendant survived confirmed cleanup", status, err) + } + if _, err := os.Stat(filepath.Join(dir, "late")); !os.IsNotExist(err) { + t.Fatal("descendant wrote after cleanup") + } + }) + } +} + +func TestWindowsJobClosesAfterOwnerCrash(t *testing.T) { + dir := t.TempDir() + owner := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "owner", "cancel", dir) + owner.Env = append(os.Environ(), "OAC_TREE_HELPER=1") + if err := owner.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }() + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + if err = owner.Process.Kill(); err != nil { + t.Fatal(err) + } + if status, err := windows.WaitForSingleObject(handle, 5000); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("Job descendant survived owner crash", status, err) + } +} + +func waitWindowsPID(t *testing.T, path string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(path) + pid, err := strconv.Atoi(string(raw)) + if err == nil && pid > 0 { + return pid + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not start") + return 0 +} + +func TestWindowsTreeHelper(t *testing.T) { + if os.Getenv("OAC_TREE_HELPER") != "1" { + return + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) != 4 { + os.Exit(2) + } + role, mode, dir := args[1], args[2], args[3] + switch role { + case "child": + _ = os.WriteFile(filepath.Join(dir, "child"), []byte(strconv.Itoa(os.Getpid())), 0600) + time.Sleep(10 * time.Second) + _ = os.WriteFile(filepath.Join(dir, "late"), nil, 0600) + case "leader": + c := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "child", mode, dir) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + for { + if _, err := os.Stat(filepath.Join(dir, "exit")); err == nil { + os.Exit(0) + } + time.Sleep(10 * time.Millisecond) + } + case "owner": + p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ(), OwnProcessGroup: true}) + if err != nil { + os.Exit(3) + } + _ = p.Wait() + default: + os.Exit(2) + } + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go new file mode 100644 index 000000000..ccc3bae11 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package clirunner + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +var isProcessInJob = windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob") + +// A PID list alone cannot confirm exit: retain handles until termination signals +// each process. Check membership after opening to avoid a recycled foreign PID. +func jobProcessHandles(job windows.Handle) ([]windows.Handle, error) { + pids, err := jobProcessIDs(job) + if err != nil { + return nil, err + } + var handles []windows.Handle + for _, pid := range pids { + process, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + continue + } + if err != nil { + return handles, err + } + var belongs int32 + ok, _, queryErr := isProcessInJob.Call(uintptr(process), uintptr(job), uintptr(unsafe.Pointer(&belongs))) + if ok == 0 { + windows.CloseHandle(process) + return handles, queryErr + } + if belongs == 0 { + windows.CloseHandle(process) + continue + } + handles = append(handles, process) + } + return handles, nil +} + +func jobProcessIDs(job windows.Handle) ([]uintptr, error) { + type processList struct { + Assigned, Count uint32 + First uintptr + } + for size := 64; ; size *= 2 { + // uintptr storage keeps the variable-length Windows structure aligned. + storage := make([]uintptr, size+2) + list := (*processList)(unsafe.Pointer(&storage[0])) + err := windows.QueryInformationJobObject(job, windows.JobObjectBasicProcessIdList, uintptr(unsafe.Pointer(list)), uint32(len(storage)*int(unsafe.Sizeof(uintptr(0)))), nil) + if errors.Is(err, windows.ERROR_MORE_DATA) || (err == nil && list.Count < list.Assigned) { + continue + } + if err != nil { + return nil, err + } + if list.Count > uint32(size) { + return nil, fmt.Errorf("clirunner: invalid Job process count") + } + return append([]uintptr(nil), unsafe.Slice(&list.First, int(list.Count))...), nil + } +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go new file mode 100644 index 000000000..3b2a25796 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go @@ -0,0 +1,23 @@ +//go:build darwin + +package clirunner + +import ( + "errors" + "golang.org/x/sys/unix" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) || errors.Is(err, unix.ESRCH) || err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go new file mode 100644 index 000000000..9d8818b38 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package clirunner + +import ( + "fmt" + "os" + "strings" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if os.IsNotExist(err) { + return + } + if err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) > 0 && fields[0] == "Z" { + return + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go index 2eace2ad1..61f85d962 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment.go +++ b/apps/parsar-daemon/internal/agent/codex/environment.go @@ -39,12 +39,6 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) return result.Status, nil } -func configureRestrictedShellEnvironment(plan *SessionPlan) { - plan.ExtraConfig = append(plan.ExtraConfig, - [2]string{"shell_environment_policy.inherit", `"core"`}, - [2]string{"shell_environment_policy.ignore_default_excludes", "false"}) -} - // 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. diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go index 557a42714..a42dc8c62 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -9,19 +9,9 @@ import ( // SupportsLocalEnvironment checks deployment prerequisites, not public admission. func SupportsLocalEnvironment(version string) bool { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" { + if !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { return false } binding, err := localworkspace.Load() return err == nil && binding != nil } - -// SupportsLocalNetworkPolicy describes the qualified adapter and bound policy; -// actual native preparation still validates the managed requirements. -func SupportsLocalNetworkPolicy(version string) bool { - if !SupportsLocalEnvironment(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") != "managed-workspace" { - return false - } - binding, err := localworkspace.Load() - return err == nil && binding != nil && binding.NetworkPolicy().Validate() == nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go new file mode 100644 index 000000000..b64ba3756 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -0,0 +1,176 @@ +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, observeTools: base.observeTools, + 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 new file mode 100644 index 000000000..5d4ecbc97 --- /dev/null +++ b/apps/parsar-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-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, "codex_provider": map[string]any{"base_url": endpoint, "wire_api": "responses", "bearer_token": 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 new file mode 100644 index 000000000..69ca2b173 --- /dev/null +++ b/apps/parsar-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-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 new file mode 100644 index 000000000..0579e81fa --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor_turn.go @@ -0,0 +1,175 @@ +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/function_receipts.go b/apps/parsar-daemon/internal/agent/codex/function_receipts.go index 7a70518d4..8c594209d 100644 --- a/apps/parsar-daemon/internal/agent/codex/function_receipts.go +++ b/apps/parsar-daemon/internal/agent/codex/function_receipts.go @@ -26,6 +26,10 @@ type pendingFunction struct { } func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() if s.functions == nil { return agent.ErrUnknownFunctionCall } @@ -59,7 +63,11 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio // This operation owns both transport failure and native application. A generic // write deadline must not close the process after a native receipt has won. written := make(chan error, 1) + if !s.beginOperation() { + return agent.ErrUnknownFunctionCall + } go func() { + defer s.endOperation() if err := ctx.Err(); err != nil { written <- err return diff --git a/apps/parsar-daemon/internal/agent/codex/installation.go b/apps/parsar-daemon/internal/agent/codex/installation.go new file mode 100644 index 000000000..3153759c9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/installation.go @@ -0,0 +1,31 @@ +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/managed_network.go b/apps/parsar-daemon/internal/agent/codex/managed_network.go deleted file mode 100644 index 2fad0c583..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network.go +++ /dev/null @@ -1,113 +0,0 @@ -package codex - -import ( - "bytes" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -const nativeManagedRequirements = "/etc/codex/requirements.toml" - -// Preserve the image's native filesystem, approval and hook requirements. This -// adapter adds the exact network ceiling; Core never supplies native TOML. -func managedNetworkRequirements(base []byte, policy agentnetwork.Policy) ([]byte, error) { - if policy.Access != "restricted" || policy.Validate() != nil { - return nil, errors.New("codex: invalid restricted network policy") - } - var config map[string]any - if _, err := toml.Decode(string(base), &config); err != nil { - return nil, err - } - if _, exists := config["experimental_network"]; exists { - return nil, errors.New("codex: image already defines managed network requirements") - } - domains := make(map[string]string) - for _, host := range policy.Hosts() { - domains[host] = "allow" - } - var addition bytes.Buffer - err := toml.NewEncoder(&addition).Encode(map[string]any{"experimental_network": map[string]any{ - "enabled": true, "managed_allowed_domains_only": true, - "allow_upstream_proxy": false, "dangerously_allow_all_unix_sockets": false, - "allow_local_binding": false, "domains": domains, - }}) - if err != nil { - return nil, err - } - result := append(append([]byte{}, base...), '\n') - return append(result, addition.Bytes()...), nil -} - -func prepareManagedNetwork(plan *SessionPlan, policy agentnetwork.Policy) error { - var home string - for _, entry := range plan.Env { - if value, found := strings.CutPrefix(entry, "CODEX_HOME="); found { - home = value - } - } - if !filepath.IsAbs(home) { - return errors.New("codex: managed network requires private Session state") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - return err - } - contents, err := managedNetworkRequirements(base, policy) - if err != nil { - return err - } - file, err := os.CreateTemp(home, ".managed-network-*.toml") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if _, err = file.Write(contents); err == nil { - err = file.Chmod(0400) - } - closeErr := file.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - path := filepath.Join(home, "managed-network.toml") - if err = os.Rename(file.Name(), path); err != nil { - return err - } - // Retain this non-secret file with Session state. Rebuild it from the frozen - // policy on preparation; cleanup never races a process still holding its mount. - plan.managedRequirements = path - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.network_proxy", "true"}) - return nil -} - -// The existing RPC client owns the sole child, its pipes and teardown. The PID -// namespace terminates native descendants when that owned process is killed. -func configureManagedNetworkProcess(cfg *JSONRPCConfig, requirements string) error { - if requirements == "" { - return nil - } - if !filepath.IsAbs(requirements) { - return errors.New("codex: managed requirements path must be absolute") - } - binary, err := exec.LookPath(cfg.Binary) - if err != nil { - return err - } - binary, err = filepath.Abs(binary) - if err != nil { - return err - } - cfg.Binary = "/usr/bin/bwrap" - cfg.ExtraArgs = append([]string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", - "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", requirements, - nativeManagedRequirements, binary}, cfg.ExtraArgs...) - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go deleted file mode 100644 index 0ff3dbdd0..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build linux - -package codex - -import ( - "bytes" - "context" - "os" - "path/filepath" - stdstrconv "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -// Run inside the qualified Docker Runtime. This checks the real native process -// and managed requirements, not model execution or public protocol acceptance. -func TestManagedNetworkNativeLifecycle(t *testing.T) { - if os.Getenv("OAC_TEST_CODEX_MANAGED_NETWORK_LIVE") != "1" { - t.Skip("requires the qualified Docker Runtime and native Codex") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - t.Fatal(err) - } - for _, mode := range []string{"close", "owner-cancel"} { - t.Run(mode, func(t *testing.T) { - home := t.TempDir() - plan := SessionPlan{Env: []string{"CODEX_HOME=" + home}} - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com"}} - if err := prepareManagedNetwork(&plan, policy); err != nil { - t.Fatal(err) - } - cfg := JSONRPCConfig{Binary: "/usr/local/bin/codex", Env: append(os.Environ(), plan.Env...)} - for _, entry := range plan.ExtraConfig { - cfg.ExtraArgs = append(cfg.ExtraArgs, "-c", entry[0]+"="+entry[1]) - } - if err := configureManagedNetworkProcess(&cfg, plan.managedRequirements); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - client := NewJSONRPCClient(cfg) - defer client.Close() - if _, err := client.Start(ctx, InitializeParams{ - ClientInfo: InitializeClientInfo{Name: "network-lifecycle-test", Version: "1"}, - Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, - }); err != nil { - t.Fatal(err) - } - requirements, err := client.Request(ctx, "configRequirements/read", nil) - if err != nil || !bytes.Contains(requirements, []byte("example.com")) { - t.Fatalf("native policy was not applied: %s: %v", requirements, err) - } - owned := networkNativeProcesses(t, home) - if len(owned) == 0 { - t.Fatal("native child was not observed") - } - if mode == "owner-cancel" { - cancel() - } - if err := client.Close(); err != nil { - t.Fatal(err) - } - deadline := time.Now().Add(5 * time.Second) - for { - survivors := networkNativeProcesses(t, home) - if len(survivors) == 0 { - break - } - if time.Now().After(deadline) { - t.Fatalf("native processes survived %s: %v", mode, survivors) - } - time.Sleep(20 * time.Millisecond) - } - if client.Alive() || client.cmd.ProcessState == nil { - t.Fatal("RPC owner did not settle") - } - }) - } - after, err := os.ReadFile(nativeManagedRequirements) - if err != nil || !bytes.Equal(base, after) { - t.Fatal("image requirements changed", err) - } -} - -func networkNativeProcesses(t *testing.T, home string) []string { - t.Helper() - entries, err := os.ReadDir("/proc") - if err != nil { - t.Fatal(err) - } - var found []string - for _, entry := range entries { - pid, err := stdstrconv.Atoi(entry.Name()) - if err != nil || pid == os.Getpid() { - continue - } - env, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "environ")) - if err == nil { - for _, value := range strings.Split(string(env), "\x00") { - if value == "CODEX_HOME="+home { - found = append(found, entry.Name()) - } - } - } - } - return found -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_test.go deleted file mode 100644 index 6348af028..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -func TestManagedNetworkPreservesImageRequirements(t *testing.T) { - base, err := os.ReadFile("../../../../../services/agents-api/deploy/codex/requirements.toml") - if err != nil { - t.Fatal(err) - } - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com", "api.example.com", "example.com"}} - result, err := managedNetworkRequirements(base, policy) - if err != nil { - t.Fatal(err) - } - var original, merged map[string]any - if _, err = toml.Decode(string(base), &original); err != nil { - t.Fatal(err) - } - if _, err = toml.Decode(string(result), &merged); err != nil { - t.Fatal(err) - } - network := merged["experimental_network"].(map[string]any) - delete(merged, "experimental_network") - if !reflect.DeepEqual(merged, original) { - t.Fatal("native filesystem/approval/hooks changed") - } - for _, flag := range []string{"enabled", "managed_allowed_domains_only"} { - if network[flag] != true { - t.Fatal("missing ceiling", flag) - } - } - for _, flag := range []string{"allow_upstream_proxy", "dangerously_allow_all_unix_sockets", "allow_local_binding"} { - if network[flag] != false { - t.Fatal("network escape enabled", flag) - } - } - want := map[string]any{"example.com": "allow", "api.example.com": "allow"} - if !reflect.DeepEqual(network["domains"], want) { - t.Fatal("exact domains changed", network["domains"]) - } - if _, err := managedNetworkRequirements(result, policy); err == nil { - t.Fatal("image ceiling overwritten") - } -} - -func TestManagedNetworkWrapsTheExistingRPCCommand(t *testing.T) { - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "managed-network.toml") - cfg := JSONRPCConfig{Binary: binary, ExtraArgs: []string{"-c", "features.network_proxy=true"}, Env: []string{"HOME=/private"}, Cwd: "/workspace"} - if err := configureManagedNetworkProcess(&cfg, path); err != nil { - t.Fatal(err) - } - if cfg.Binary != "/usr/bin/bwrap" || cfg.Cwd != "/workspace" || !reflect.DeepEqual(cfg.Env, []string{"HOME=/private"}) { - t.Fatal("RPC ownership/configuration changed") - } - want := []string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", path, nativeManagedRequirements, binary, "-c", "features.network_proxy=true"} - if !reflect.DeepEqual(cfg.ExtraArgs, want) { - t.Fatal(cfg.ExtraArgs) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go index 6ec33f262..d583078ca 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -2,6 +2,7 @@ package codex import ( "encoding/json" + "os" "slices" "strings" "testing" @@ -13,15 +14,16 @@ import ( func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { token := "user-token" local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ - {PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, - {PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, + {InstallationRoot: "/private/runtime/capabilities", 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 := mergeEnvironmentMCP(nil, local) if err != nil || len(servers) != 2 || len(env) != 2 { t.Fatal("environment declarations were not projected", err) } - if servers["local"].Command != "/usr/bin/python3" || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "plugins/0", "local"}) { + 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"] @@ -41,7 +43,7 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. if !matchesMCPConfig(raw, servers) { t.Fatal("qualified native projection rejected") } - corrupt := strings.Replace(string(raw), "oac-runtime-initialize", "untrusted-launcher", 1) + corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) if matchesMCPConfig(json.RawMessage(corrupt), servers) { t.Fatal("different native launcher accepted") } diff --git a/apps/parsar-daemon/internal/agent/codex/native_hooks.go b/apps/parsar-daemon/internal/agent/codex/native_hooks.go new file mode 100644 index 000000000..962ca1969 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/native_hooks.go @@ -0,0 +1,39 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +type nativeToolHook struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` +} + +func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) + if err != nil { + return nil, errors.New("codex: initialized tool hook unavailable") + } + var response struct { + Data []struct { + CWD string `json:"cwd"` + Hooks []nativeToolHook `json:"hooks"` + Errors []json.RawMessage `json:"errors"` + } `json:"data"` + } + if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { + return nil, errors.New("codex: initialized tool hook configuration unavailable") + } + return response.Data[0].Hooks, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 001a9c837..e46e553af 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -15,7 +15,6 @@ import ( // SessionPlan holds the resolved per-prompt launch plan derived from // the daemon's PromptRequestPayload. type SessionPlan struct { - managedRequirements string // Cwd is the validated working directory passed to codex (and to // the spawned app-server). Empty when the caller provided no work_dir. diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go index 953442a22..a5d7a76d9 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -1,52 +1,14 @@ package codex import ( - "errors" - "strings" - + "fmt" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) -// The deployment selects a native named profile; request options cannot select it. -// Native managed requirements must constrain its definition and allowed profiles. -// This selection does not establish workspace authority or public admission. -func validatePermissionProfile(req proto.PromptRequestPayload, profile string) error { - if req.LocalEnvironment != nil && profile == "" { - return errors.New("codex: local Environment requires deployment-managed permissions") - } - if profile == "" { - return nil - } - if strings.TrimSpace(profile) != profile || strings.HasPrefix(profile, ":") { - return errors.New("codex: deployment permissions require a named native profile") - } - if req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return errors.New("codex: deployment permission profile requires local execution") - } - return nil -} - -// managedPermissionProfile maps a validated deployment binding to native policy. -// Public or prompt options cannot choose a native profile or widen that binding. -func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) (string, error) { - profile := cfg.permissionProfile - if cfg.runtimeNetworkError != nil { - return "", cfg.runtimeNetworkError - } - if cfg.runtimeNetwork.Access != "" { - if req.LocalEnvironment == nil || !cfg.runtimeNetwork.Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || profile != "managed-workspace" { - return "", errors.New("codex: Runtime network policy mismatch") - } - switch cfg.runtimeNetwork.Access { - case "disabled": - case "enabled", "restricted": - profile = "managed-workspace-enabled" - default: - return "", errors.New("codex: unsupported Runtime network policy") - } - } else if req.LocalEnvironment != nil { - return "", errors.New("codex: Runtime has no bound network policy") +// Runtime execution uses the current user; isolation belongs to its outer host. +func runtimePermissionProfile(req proto.PromptRequestPayload) (string, error) { + if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || req.WorkspaceReadOnly || req.LocalEnvironment.NetworkAccess != "enabled" || len(req.LocalEnvironment.AllowedDomains) != 0) { + return "", fmt.Errorf("codex: Runtime execution requires unrestricted host access") } - return profile, validatePermissionProfile(req, profile) + return "", nil } diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 585c0179b..e8ec49b35 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -1,92 +1,65 @@ package codex import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "os" "path/filepath" + "strings" "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func TestPermissionProfileRejectsIncompatiblePreparationBeforeState(t *testing.T) { - for _, tc := range []struct { - name string - profile string - req proto.PromptRequestPayload - }{ - {"builtin", ":danger-full-access", proto.PromptRequestPayload{}}, - {"whitespace", " ", proto.PromptRequestPayload{}}, - {"none", "managed-workspace", proto.PromptRequestPayload{DisableExecutionEnvironment: true}}, - {"read-owner", "managed-workspace", proto.PromptRequestPayload{WorkspaceReadOnly: true}}, - } { - t.Run(tc.name, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - if _, _, err := prepareSessionPlan(context.Background(), tc.req, sessionConfig{permissionProfile: tc.profile}); err == nil { - t.Fatal("incompatible profile accepted") +func TestRuntimeUsesHostPermissions(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + { + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) + } + if plan.Sandbox != "danger-full-access" || plan.Permissions != "" || plan.ApprovalPolicy.String != "never" { + t.Fatal("Runtime must bypass inner sandbox", plan) + } + for _, kv := range plan.ExtraConfig { + if kv[0] == "default_permissions" { + t.Fatal("obsolete permission wrapper", kv) } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("rejection created state", err) + } + plan.Cleanup() + for _, network := range []string{"disabled", "restricted"} { + req.LocalEnvironment.NetworkAccess = network + if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}); err == nil { + t.Fatal("unsupported network admitted") } - }) + } } } -func TestPermissionProfileSelectsNativeStartupConfig(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE", "managed-workspace") - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true}, defaultSessionConfig()) - if err != nil { +func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + workspace := filepath.Join(root, "workspace") + if err := os.Mkdir(workspace, 0700); err != nil { t.Fatal(err) } - defer plan.Cleanup() - config := map[string]string{} - for _, kv := range plan.ExtraConfig { - config[kv[0]] = kv[1] + config := filepath.Join(root, "tool-env.json") + if err := os.WriteFile(config, []byte(`{"CODEX_HOME":"wrong","HOME":"wrong","USERPROFILE":"wrong","USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) } - if config["default_permissions"] != `"managed-workspace"` || plan.Sandbox != "" || plan.Permissions != "managed-workspace" { - t.Fatal("native managed selection missing") + for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { + t.Setenv(key, value) } - if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { - t.Fatal("shell can inherit model credentials") + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) } - if config["features.shell_snapshot"] != "false" { - t.Fatal("managed shell depends on inaccessible private snapshots") + defer plan.Cleanup() + values := map[string]string{} + for _, entry := range plan.Env { + key, value, _ := strings.Cut(entry, "=") + values[key] = value } -} - -func TestManagedNetworkPolicySelectsNativeProfileAndRejectsMismatchBeforeState(t *testing.T) { - for _, mode := range []string{"enabled", "disabled"} { - t.Run(mode, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - req := proto.PromptRequestPayload{AgentStateKey: "session", LocalEnvironment: &proto.LocalEnvironment{ID: "environment", NetworkAccess: mode}} - cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetwork: agentnetwork.Policy{Access: mode}} - wrong := req - wrong.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted"} - if _, _, err := prepareSessionPlan(t.Context(), wrong, cfg); err == nil { - t.Fatal("policy mismatch accepted") - } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("policy rejection created native state") - } - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - expected := "managed-workspace" - if mode == "enabled" { - expected += "-enabled" - } - if plan.Permissions != expected || plan.Sandbox != "" { - t.Fatal("wrong native profile", plan.Permissions) - } - if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{permissionProfile: "managed-workspace"}); err == nil { - t.Fatal("unbound Runtime accepted explicit policy") - } - }) + if values["CODEX_HOME"] == "" || values["CODEX_HOME"] == "wrong" || values["HOME"] == "wrong" || values["USERPROFILE"] == "wrong" || values["USER_VALUE"] != "ready" { + t.Fatal("initialization changed native state identity") } } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 3c22dcb04..5f7578418 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -83,17 +83,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } - if err := configureManagedNetworkProcess(&rpcCfg, plan.managedRequirements); err != nil { - cancelFn() - plan.Cleanup() - return nil, err - } rpc := NewJSONRPCClient(rpcCfg) s := &Session{ nativeHome: nativeHomeFromPlan(plan), - toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, functions: functions, observeMessages: req.ObserveMessages, observeTools: req.ObserveTools, @@ -130,10 +124,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } if req.DisableExecutionEnvironment { if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, err + return p.preparationFailed(err) } } if s.observeSubagentIdentities { @@ -141,25 +132,15 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return p.preparationFailed(err) } } - if s.toolEnvironment { - if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { - return p.preparationFailed(err) - } - } + if plan.mcpServers != nil { if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, err + return p.preparationFailed(err) } } if len(skillRoots) > 0 { if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, fmt.Errorf("codex: register skill root: %w", err) + return p.preparationFailed(fmt.Errorf("codex: register skill root: %w", err)) } } @@ -168,6 +149,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { - _ = p.Close() + 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_helpers_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go index d1b5a7267..2957a4718 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -3,6 +3,7 @@ package codex import ( "bufio" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -139,6 +140,9 @@ func TestPreparationFakeCodexProcess(t *testing.T) { frames := json.NewEncoder(log) output := json.NewEncoder(os.Stdout) scanner := bufio.NewScanner(os.Stdin) + turnNumber := 0 + currentTurn := "" + executorMode := os.Getenv("OAC_TEST_EXECUTOR_MODE") for scanner.Scan() { var frame preparationFrame if json.Unmarshal(scanner.Bytes(), &frame) != nil { @@ -181,12 +185,70 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } } result = map[string]any{"thread": map[string]string{"id": "fixture-native-thread"}, "model": "fixture-model"} + case "thread/backgroundTerminals/list": + result = map[string]any{"data": []any{}} + if executorMode == "terminal-cleanup" { + if _, err := os.Stat(os.Getenv("OAC_TEST_PREPARATION_FRAMES") + ".terminated"); os.IsNotExist(err) { + result = map[string]any{"data": []any{map[string]string{"processId": "owned-terminal"}}} + } + } + case "thread/backgroundTerminals/terminate": + var target struct { + ThreadID string `json:"threadId"` + ProcessID string `json:"processId"` + } + if json.Unmarshal(frame.Params, &target) != nil || target.ThreadID != "fixture-native-thread" || target.ProcessID != "owned-terminal" { + os.Exit(8) + } + _, allowed := os.Stat(os.Getenv("OAC_TEST_PREPARATION_FRAMES") + ".allow-cleanup") + result = map[string]any{"terminated": allowed == nil} + if allowed == nil { + _ = os.WriteFile(os.Getenv("OAC_TEST_PREPARATION_FRAMES")+".terminated", nil, 0600) + } + case "turn/interrupt": + if executorMode == "interrupt-error" { + _ = output.Encode(map[string]any{"id": frame.ID, "error": map[string]any{"code": -32603, "message": "interrupt rejected"}}) + continue + } case "turn/start": + if executorMode != "" { + turnNumber++ + currentTurn = fmt.Sprintf("fixture-turn-%d", turnNumber) + if turnNumber > 1 { + _ = output.Encode(map[string]any{"method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": fmt.Sprintf("fixture-turn-%d", turnNumber-1)}}}) + } + if executorMode == "disconnect" { + os.Exit(0) + } + if executorMode == "start-error" { + _ = output.Encode(map[string]any{"id": frame.ID, "error": map[string]any{"code": -32603, "message": "start rejected"}}) + continue + } + result = map[string]any{"turn": map[string]string{"id": currentTurn}} + break + } result = map[string]any{"turn": map[string]string{"id": "fixture-native-turn"}} } if output.Encode(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": result}) != nil { os.Exit(5) } + + if executorMode != "" && (frame.Method == "turn/start" || frame.Method == "turn/interrupt") { + if frame.Method == "turn/interrupt" && executorMode == "interrupt-no-terminal" { + continue + } + if frame.Method == "turn/start" { + _ = output.Encode(map[string]any{"method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": currentTurn}}}) + } + if frame.Method == "turn/interrupt" || !strings.Contains(string(frame.Params), "hold") { + status := "completed" + if frame.Method == "turn/interrupt" { + status = "interrupted" + } + _ = output.Encode(map[string]any{"method": "turn/completed", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": currentTurn, "status": status}}}) + } + continue + } if frame.Method == "turn/start" { _ = output.Encode(map[string]any{"jsonrpc": "2.0", "method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": "fixture-native-turn"}}}) if os.Getenv("OAC_TEST_PREPARATION_OBSERVE") == "1" { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index 15bf5d432..4078d3b99 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -4,9 +4,10 @@ import ( "context" "errors" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/google/uuid" "os" + "path/filepath" "testing" "time" @@ -30,16 +31,20 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { for _, start := range []bool{false, true} { t.Run(map[bool]string{false: "disconnect-before-start", true: "transfer-and-cancel"}[start], func(t *testing.T) { req, cfg, root := preparationFixture(t) + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", filepath.Join(t.TempDir(), "capabilities")) + t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") environment, session := uuid.NewString(), uuid.NewString() if err := os.MkdirAll(req.WorkDir, 0700); err != nil { t.Fatal(err) } for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": environment, - "OAC_RUNTIME_SESSION_ID": session, - "OAC_RUNTIME_WORKSPACE": req.WorkDir, - "OAC_RUNTIME_DIRECTORY_HELPER": cfg.codexBinary, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", + "OAC_RUNTIME_ENVIRONMENT_ID": environment, + "OAC_RUNTIME_SESSION_ID": session, + "OAC_RUNTIME_WORKSPACE": req.WorkDir, + "OAC_RUNTIME_NETWORK_ACCESS": "enabled", } { t.Setenv(key, value) } @@ -50,21 +55,19 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.WorkDir = "" req.AgentStateKey = "agents-api-" + session req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, NetworkAccess: "enabled"} - cfg.permissionProfile = "managed-workspace" - cfg.runtimeNetwork = agentnetwork.Policy{Access: "enabled"} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} registry := agent.NewRegistry() registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("ordinary Factory must not run") }) prepared := make(chan *Prepared, 1) - registry.RegisterPreparation("codex", false, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - p, err := newPreparation(ctx, req, cfg) + registry.RegisterExecutor("codex", func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + e, err := newExecutor(ctx, req, cfg) if err != nil { return nil, err } - prepared <- p - return p, nil + prepared <- e.prepared + return e, nil }) sender := make(preparationWireSender, 64) r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, LocalWorkspace: binding}) @@ -110,7 +113,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { } } } - send(proto.TypeExecutionPrepare, "prepare-request", proto.ExecutionPreparePayload{Configuration: req}) + send(proto.TypeExecutionPrepare, "prepare-request", proto.ExecutionPreparePayload{SessionID: session, Configuration: req}) ready := await("ready") p := <-prepared assertPreparationOnly(t, root) @@ -119,7 +122,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { t.Fatal("preparation became a Run") } if start { - input := proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "actual-run", Input: proto.TextInput("actual input")} + input := proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "actual-run", Input: proto.TextInput("hold")} send(proto.TypeExecutionStart, "prepare-request", input) await("started") frames := waitPreparationMethod(t, root, "turn/start") diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/parsar-daemon/internal/agent/codex/prepared.go index a83cf6ffe..b4e3a0b78 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared.go @@ -88,7 +88,9 @@ func (p *Prepared) Close() error { p.mu.Unlock() p.session.cancelFn() err := p.session.rpc.Close() - p.plan.Cleanup() + if err == nil { + p.plan.Cleanup() + } return err } diff --git a/apps/parsar-daemon/internal/agent/codex/rpc.go b/apps/parsar-daemon/internal/agent/codex/rpc.go index ec52afebb..19a232309 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc.go @@ -14,6 +14,8 @@ import ( "sync" "time" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -79,10 +81,11 @@ type JSONRPCConfig struct { type JSONRPCClient struct { cfg JSONRPCConfig - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser + process *clirunner.Process + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser mu sync.Mutex alive bool @@ -91,11 +94,12 @@ type JSONRPCClient struct { pendingMu sync.Mutex pending map[string]*pendingRequest + handlerWork sync.WaitGroup handlersMu sync.RWMutex notifHandlers map[string][]NotificationHandler serverReqHandlers map[string]ServerRequestHandler - anyNotifHandler NotificationHandler + readers sync.WaitGroup closeOnce sync.Once doneCh chan struct{} } @@ -176,38 +180,23 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi args = append(args, "--disable", f) } - cmd := exec.CommandContext(ctx, c.cfg.Binary, args...) - cmd.Dir = c.cfg.Cwd - if len(c.cfg.Env) > 0 { - cmd.Env = append([]string{}, c.cfg.Env...) - } - - stdin, err := cmd.StdinPipe() - if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stdin pipe: %w", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() + process, err := clirunner.Start(clirunner.StartOptions{ + Parent: ctx, Binary: c.cfg.Binary, Args: args, Dir: c.cfg.Cwd, Env: c.cfg.Env, + NeedStdin: true, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + }) if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { return InitializeResult{}, fmt.Errorf("codex rpc: spawn %q: %w", c.cfg.Binary, err) } - - c.cmd = cmd - c.stdin = stdin - c.stdout = stdout - c.stderr = stderr c.mu.Lock() + c.process = process + c.cmd = process.Cmd + c.stdin, c.stdout, c.stderr = process.Stdin, process.Stdout, process.Stderr c.alive = true c.mu.Unlock() - go c.readStdoutLoop() - go c.pumpStderr() + c.readers.Add(2) + go func() { defer c.readers.Done(); c.readStdoutLoop() }() + go func() { defer c.readers.Done(); c.pumpStderr() }() go c.waitChild() initCtx, cancel := context.WithTimeout(ctx, rpcInitTimeout) @@ -273,14 +262,6 @@ func (c *JSONRPCClient) OnNotification(method string, h NotificationHandler) { c.notifHandlers[method] = append(c.notifHandlers[method], h) } -// OnAnyNotification registers a fallback handler. Used by session.go for -// debug logging of unmodeled methods. -func (c *JSONRPCClient) OnAnyNotification(h NotificationHandler) { - c.handlersMu.Lock() - defer c.handlersMu.Unlock() - c.anyNotifHandler = h -} - // OnServerRequest registers h for inbound server requests with method. // Only one handler per method is permitted; later Registers override. func (c *JSONRPCClient) OnServerRequest(method string, h ServerRequestHandler) { @@ -414,18 +395,18 @@ func (c *JSONRPCClient) handleNotification(method string, rawFrame []byte) { _ = json.Unmarshal(rawFrame, &env) c.handlersMu.RLock() handlers := append([]NotificationHandler{}, c.notifHandlers[method]...) - any := c.anyNotifHandler + if len(handlers) > 0 { + c.handlerWork.Add(1) + } c.handlersMu.RUnlock() - if len(handlers) == 0 && any == nil { + if len(handlers) == 0 { c.cfg.Logger.Debug("codex rpc unhandled notification", "tag", c.cfg.LogTag, "method", method) return } + defer c.handlerWork.Done() for _, h := range handlers { safeInvoke(h, env.Params, c.cfg.Logger, c.cfg.LogTag, "notification "+method) } - if any != nil { - safeInvoke(any, env.Params, c.cfg.Logger, c.cfg.LogTag, "notification "+method) - } } func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessage, method string) { @@ -438,6 +419,9 @@ func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessa c.handlersMu.RLock() h, ok := c.serverReqHandlers[method] + if ok { + c.handlerWork.Add(1) + } c.handlersMu.RUnlock() if !ok { // Method-not-found per JSON-RPC 2.0 — codex hangs if we drop it. @@ -445,6 +429,7 @@ func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessa return } go func() { + defer c.handlerWork.Done() defer func() { if r := recover(); r != nil { _ = c.SendServerError(id, -32603, fmt.Sprintf("panic in %s handler: %v", method, r), nil) @@ -474,7 +459,10 @@ func (c *JSONRPCClient) pumpStderr() { func (c *JSONRPCClient) waitChild() { defer close(c.doneCh) - err := c.cmd.Wait() + // The common owner reaps the leader and terminates its descendants independently. + // Drain complete RPC frames before Wait closes the owned output handles. + c.readers.Wait() + err := c.process.Wait() c.mu.Lock() c.alive = false if c.cmd.ProcessState != nil { @@ -525,3 +513,24 @@ func truncate(b []byte, n int) []byte { } return b[:n] } + +// detachHandlers fences registration before joining captured callbacks, including +// asynchronous server replies. New notifications have no Turn destination. +func (c *JSONRPCClient) detachHandlers() { + c.handlersMu.Lock() + clear(c.notifHandlers) + clear(c.serverReqHandlers) + c.handlersMu.Unlock() + c.handlerWork.Wait() +} + +func (c *JSONRPCClient) awaitReaders(ctx context.Context) error { + done := make(chan struct{}) + go func() { c.readers.Wait(); close(done) }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close.go b/apps/parsar-daemon/internal/agent/codex/rpc_close.go index 94901ad7e..8d55e0e29 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close.go @@ -7,32 +7,32 @@ import ( "time" ) -// Close initiates shutdown once and waits for the owned child to be reaped. +// Close waits for the common owner to settle the native process and descendants. func (c *JSONRPCClient) Close() error { defer func() { _ = c.drainPending(errors.New("codex rpc: client closed")) }() c.closeOnce.Do(func() { c.mu.Lock() - cmd := c.cmd + process := c.process stdin := c.stdin c.alive = false c.mu.Unlock() if stdin != nil { _ = stdin.Close() } - if cmd != nil && cmd.Process != nil { + if process != nil { grace := time.NewTimer(250 * time.Millisecond) defer grace.Stop() select { case <-c.doneCh: case <-grace.C: - _ = cmd.Process.Kill() + process.Cancel() } } }) c.mu.Lock() - cmd := c.cmd + process := c.process c.mu.Unlock() - if cmd == nil || cmd.Process == nil { + if process == nil { return nil } wait := time.NewTimer(rpcKillTimeout) diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go index 9aed5ef85..cc4c18ebb 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go @@ -6,33 +6,26 @@ import ( "errors" "io" "os" - "os/exec" "sync" "sync/atomic" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" ) func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { - cmd := exec.Command(os.Args[0], "-test.run=^TestJSONRPCClientFakeCodexProcess$", "--") - cmd.Env = append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0") - stdin, err := cmd.StdinPipe() - if err != nil { - t.Fatal(err) - } - stdout, err := cmd.StdoutPipe() + 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) } - if err := cmd.Start(); err != nil { - t.Fatal(err) - } + cmd, stdin, stdout := process.Cmd, process.Stdin, process.Stdout client := NewJSONRPCClient(JSONRPCConfig{}) input := &countedCloseWriter{WriteCloser: stdin} - client.cmd, client.stdin, client.stdout, client.alive = cmd, input, stdout, true + client.process, client.cmd, client.stdin, client.stdout, client.alive = process, cmd, input, stdout, true var reap sync.Once t.Cleanup(func() { - _ = cmd.Process.Kill() + process.Cancel() reap.Do(client.waitChild) }) if _, err := io.WriteString(stdin, "{\"id\":\"close-test\"}\n"); err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go new file mode 100644 index 000000000..4e76bbc0e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go @@ -0,0 +1,148 @@ +package codex + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + stdstrconv "strconv" + "testing" + "time" +) + +func TestJSONRPCClientOwnsToolDescendants(t *testing.T) { + for _, mode := range []string{"close", "owner-cancel", "leader-exit", "initialize-error"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := NewJSONRPCClient(JSONRPCConfig{Binary: os.Args[0], ExtraArgs: []string{"-test.run=^TestJSONRPCDescendantHelper$", "--"}, Env: append(os.Environ(), "OAC_RPC_DESCENDANT=leader", "OAC_RPC_DESCENDANT_DIR="+dir, "OAC_RPC_DESCENDANT_MODE="+mode, "GORACE=atexit_sleep_ms=0")}) + t.Cleanup(func() { _ = client.Close() }) + _, err := client.Start(ctx, InitializeParams{}) + if mode == "initialize-error" { + if err == nil { + t.Fatal("initialization error accepted") + } + } else if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "child.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := stdstrconv.Atoi(string(raw)) + if err != nil { + t.Fatal(err) + } + child, err := os.FindProcess(pid) + if err == nil { + t.Cleanup(func() { + if t.Failed() { + _ = child.Kill() + } + _ = child.Release() + }) + } + switch mode { + case "close": + if err := client.Close(); err != nil { + t.Fatal(err) + } + case "owner-cancel": + cancel() + case "leader-exit": + if err := client.Notify("exit", nil); err != nil { + t.Fatal(err) + } + } + select { + case <-client.Done(): + case <-time.After(5 * time.Second): + t.Fatal("native ownership did not settle") + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(dir, "effects")) + if err != nil || len(before) == 0 { + t.Fatal("descendant did not begin work", err) + } + time.Sleep(100 * time.Millisecond) + after, err := os.ReadFile(filepath.Join(dir, "effects")) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("tool descendant continued after native cleanup", err) + } + }) + } +} + +func TestJSONRPCDescendantHelper(t *testing.T) { + role := os.Getenv("OAC_RPC_DESCENDANT") + if role == "" { + return + } + dir := os.Getenv("OAC_RPC_DESCENDANT_DIR") + if role == "child" { + f, err := os.OpenFile(filepath.Join(dir, "effects"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + os.Exit(2) + } + defer f.Close() + for i := 0; i < 3000; i++ { + if _, err := f.WriteString("effect\n"); err != nil { + os.Exit(3) + } + time.Sleep(10 * time.Millisecond) + } + os.Exit(0) + } + child := exec.Command(os.Args[0], "-test.run=^TestJSONRPCDescendantHelper$", "--") + child.Env = append(os.Environ(), "OAC_RPC_DESCENDANT=child") + // Retain the RPC pipes as real native tool children can do. + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(4) + } + if os.WriteFile(filepath.Join(dir, "child.pid"), []byte(stdstrconv.Itoa(child.Process.Pid)), 0600) != nil { + os.Exit(5) + } + deadline := time.Now().Add(3 * time.Second) + for { + if info, err := os.Stat(filepath.Join(dir, "effects")); err == nil && info.Size() > 0 { + break + } + if time.Now().After(deadline) { + os.Exit(6) + } + time.Sleep(time.Millisecond) + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var request struct { + ID string `json:"id"` + Method string `json:"method"` + } + if json.Unmarshal(scanner.Bytes(), &request) != nil { + os.Exit(7) + } + if request.Method == "exit" { + os.Exit(0) + } + if request.ID != "" { + response := map[string]any{"id": request.ID, "result": map[string]any{}} + if os.Getenv("OAC_RPC_DESCENDANT_MODE") == "initialize-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -32603, "message": "fixture initialization failure"} + } + body, _ := json.Marshal(response) + fmt.Println(string(body)) + } + } + // Leave the leader alive after stdin closes to exercise explicit cancellation. + time.Sleep(30 * time.Second) + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/parsar-daemon/internal/agent/codex/server_requests.go index 69aa69d3c..656ac6ca7 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests.go @@ -195,6 +195,10 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err } func (s *Session) submitCodexPermission(requestID string, decision proto.PermissionDecisionPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.permissions[requestID] if ok { @@ -243,6 +247,10 @@ func (s *Session) sendCodexPermissionReply(pending pendingCodexPermission, appro } func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.asks[askID] if ok { @@ -311,6 +319,10 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse } func (s *Session) expireCodexPermission(requestID string) { + if !s.beginOperation() { + return + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.permissions[requestID] if ok { @@ -326,6 +338,10 @@ func (s *Session) expireCodexPermission(requestID string) { } func (s *Session) expireCodexAsk(askID string) { + if !s.beginOperation() { + return + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.asks[askID] if ok { diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 5c212c4e7..f53a49261 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -5,16 +5,13 @@ import ( "encoding/json" "fmt" "log/slog" - "os" "strings" "sync" "sync/atomic" "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-Dev/parsar/internal/agentnetwork" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -26,20 +23,14 @@ 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 - permissionProfile string - runtimeNetwork agentnetwork.Policy - runtimeNetworkError error - logger *slog.Logger - killTimeout time.Duration + codexBinary string + logger *slog.Logger + killTimeout time.Duration } func defaultSessionConfig() sessionConfig { - policy, err := localworkspace.RuntimeNetworkPolicy() return sessionConfig{ - codexBinary: defaultBinary(), - permissionProfile: os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE"), - runtimeNetwork: policy, runtimeNetworkError: err, + codexBinary: defaultBinary(), logger: obslog.Bg(), killTimeout: rpcKillTimeout, } @@ -63,7 +54,16 @@ func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- pro // 5. turn/completed emits TypeDone + closes out. Cancel can short-cut // this by killing the child early. type Session struct { - toolEnvironment bool + 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 @@ -150,16 +150,15 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) - rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) - rpc.OnServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) - rpc.OnServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) - rpc.OnServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) + 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. - rpc.OnServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) - rpc.OnServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) - rpc.OnServerRequest("item/tool/call", s.handleFunctionCall) - rpc.OnServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) + 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) { @@ -193,6 +192,7 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { return } + s.nativeSettled.Store(true) s.stopSteering() usage := p.Turn.Usage @@ -354,10 +354,6 @@ func (s *Session) peekLastErrText() string { // envelope emit helpers // --------------------------------------------------------------------------- -func (s *Session) emitDone(content string, usage *TurnUsage) { - s.emitDoneAt(content, usage, nil) -} - func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int64) { if !s.terminal.CompareAndSwap(false, true) { return @@ -396,6 +392,9 @@ func (s *Session) emitTerminal(message string, asError bool) { } 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 } @@ -447,6 +446,9 @@ func (s *Session) closeOut() { s.outMu.Lock() s.outClosed = true close(s.out) + if s.outputDone != nil { + close(s.outputDone) + } s.outMu.Unlock() }) } diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel.go b/apps/parsar-daemon/internal/agent/codex/session_cancel.go index 2b1fe0080..25024d5b5 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_cancel.go +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel.go @@ -7,6 +7,9 @@ import ( ) func (s *Session) Cancel(ctx context.Context) error { + if s.executor != nil { + return s.cancelExecutorTurn(ctx) + } s.cancelled.Store(true) s.cancelOnce.Do(func() { s.cancelReady = make(chan struct{}) diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 35313f715..4dad48a69 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -3,6 +3,7 @@ package codex import ( "context" "fmt" + "strings" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -12,7 +13,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err := validateNativeTransportEnvironment(req); err != nil { return SessionPlan{}, nil, err } - profile, err := managedPermissionProfile(req, cfg) + _, err := runtimePermissionProfile(req) if err != nil { return SessionPlan{}, nil, err } @@ -29,29 +30,24 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } disableProgrammaticTools(&plan, req.ExecutionControls) - if profile != "" { - plan.Sandbox = "" - plan.Permissions = profile - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"default_permissions", tomlQuoteString(profile)}) - configureRestrictedShellEnvironment(&plan) - // Native login-shell snapshots live outside the managed tool filesystem. - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) + if req.LocalEnvironment != nil { + plan.Sandbox = "danger-full-access" + plan.Permissions = "" + plan.ApprovalPolicy = AskForApproval{String: "never"} } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + if req.LocalEnvironment != nil { + values, err := localworkspace.ReadOptionalToolEnvironment() + if err != nil { plan.Cleanup() return SessionPlan{}, nil, err } - plan.Env = append(plan.Env, "OAC_RUNTIME_TOOL_ENV=1") - if req.LocalEnvironment.SystemPackages { - if err := prepareSystemToolAnchor(); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err + for key, value := range values { + if strings.EqualFold(key, "CODEX_HOME") || strings.EqualFold(key, "HOME") || strings.EqualFold(key, "USERPROFILE") { + continue } - plan.Env = append(plan.Env, "OAC_RUNTIME_SYSTEM_PACKAGES=1") + plan.Env = append(plan.Env, key+"="+value) } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.hooks", "true"}) } if req.DisableSubagents { @@ -102,11 +98,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Env = append(plan.Env, mcpBearerEnv...) plan.Env = append(plan.Env, environmentMCPEnv...) - if cfg.runtimeNetwork.Access == "restricted" { - if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - } + return plan, skillRoots, nil } diff --git a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go index 9156aca5a..869b47ecf 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go +++ b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{permissionProfile: profile}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{}) if err != nil { t.Fatal(err) } @@ -48,15 +48,10 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { if request.Params["approvalPolicy"] != "never" { t.Fatal("approval policy changed") } - if profile == "" { - if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { - t.Fatalf("default policy changed: %+v", request.Params) - } - } else { - if request.Params["sandbox"] != nil || request.Params["permissions"] != profile { - t.Fatalf("managed policy lost: %+v", request.Params) - } + if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { + t.Fatalf("bypass policy changed: %+v", request.Params) } + if method == "thread/resume" && request.Params["threadId"] != "old-thread" { t.Fatalf("resume lost thread ID: %+v", request.Params) } diff --git a/apps/parsar-daemon/internal/agent/codex/session_run.go b/apps/parsar-daemon/internal/agent/codex/session_run.go index 170708625..21736ba88 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_run.go +++ b/apps/parsar-daemon/internal/agent/codex/session_run.go @@ -16,15 +16,32 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { defer s.cleanup() defer s.closeRunOutput() - if err := s.resolveThread(req, plan); err != nil { + if err := s.startNative(s.cancelCtx, plan, req); err != nil { s.emitTerminal(err.Error(), true) return } + // Block until terminal handlers close the RPC child or cancellation arrives. + select { + case <-s.rpc.Done(): + if !s.cancelled.Load() && s.cancelCtx.Err() == nil { + s.emitTerminal("codex: connection closed before the run completed", true) + } + case <-s.cancelCtx.Done(): + _ = s.rpc.Close() + } +} + +func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.PromptRequestPayload) error { + if s.currentThreadID() == "" { + if err := s.resolveThread(req, plan); err != nil { + return err + } + } + input, err := nativeInput(req.Input) if err != nil { - s.emitTerminal("codex: empty prompt", true) - return + return err } turnParams := TurnStartParams{ ThreadID: s.currentThreadID(), @@ -33,8 +50,7 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { if plan.CollaborationMode != "" { model := strings.TrimSpace(s.resolvedModel) if model == "" { - s.emitTerminal("codex: collaboration mode requires a resolved model", true) - return + return fmt.Errorf("codex: collaboration mode requires a resolved model") } var developerInstructions *string if plan.SystemPrompt != "" { @@ -48,22 +64,13 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { }, } } - turnCtx, turnCancel := context.WithTimeout(s.cancelCtx, 10*time.Second) + turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) turnCancel() if ackErr != nil { s.cfg.logger.Warn("codex: turn/start ack failed", "run_id", s.runID, "err", ackErr) - s.emitTerminal(fmt.Sprintf("codex: turn/start: %v", ackErr), true) - return + return fmt.Errorf("codex: turn/start: %w", ackErr) } - // Block until terminal handlers close the RPC child or cancellation arrives. - select { - case <-s.rpc.Done(): - if !s.cancelled.Load() && s.cancelCtx.Err() == nil { - s.emitTerminal("codex: connection closed before the run completed", true) - } - case <-s.cancelCtx.Done(): - _ = s.rpc.Close() - } + return nil } diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering.go b/apps/parsar-daemon/internal/agent/codex/session_steering.go index 4ad2716fe..f0d6be931 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering.go +++ b/apps/parsar-daemon/internal/agent/codex/session_steering.go @@ -38,6 +38,10 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP } func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() ctx, cancel := context.WithCancel(ctx) defer cancel() s.steering.mu.Lock() @@ -96,7 +100,7 @@ func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, wri } func (s *Session) startSteering(threadID, turnID string) bool { - if !s.isRootThread(threadID) || turnID == "" { + if !s.isRootThread(threadID) || turnID == "" || s.retiredTurns[turnID] { return false } s.steering.mu.Lock() diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage.go b/apps/parsar-daemon/internal/agent/codex/session_usage.go index 309d05b99..17e672284 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_usage.go +++ b/apps/parsar-daemon/internal/agent/codex/session_usage.go @@ -30,7 +30,7 @@ func (s *Session) onUsageUpdated(raw json.RawMessage) { s.usageMu.Unlock() return } - if s.terminal.Load() { + if s.terminal.Load() || s.retiredTurns[p.TurnID] { return } var observed *TurnUsage diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go index 2899dfa03..e2ff66b20 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go @@ -21,7 +21,7 @@ func TestSubagentCancelDeadlineRetainsOwnerAndRetry(t *testing.T) { f.mu.Lock() f.interruptGate = gate f.mu.Unlock() - s.emitDone("frozen", nil) + s.emitDoneAt("frozen", nil, nil) for range 4 { select { case e := <-out: @@ -91,7 +91,7 @@ func TestSubagentCancelRetainsActualObservationFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(f.home, "sessions", "root.jsonl"), []byte("invalid\n"), 0600); err != nil { t.Fatal(err) } - s.emitDone("frozen", nil) + s.emitDoneAt("frozen", nil, nil) } select { case <-s.subagents.done: diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go index 45223670e..181da6d9f 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go @@ -180,7 +180,7 @@ func TestSubagentFactsPrecedeFrozenRootTerminal(t *testing.T) { func TestSubagentRootFirstRetainsChildUntilTerminal(t *testing.T) { s, f, out := observationSession(t, "inProgress") - s.emitDone("frozen", nil) + s.emitDoneAt("frozen", nil, nil) for i := 0; i < 4; i++ { select { case e := <-out: @@ -208,7 +208,7 @@ func TestSubagentRootFirstRetainsChildUntilTerminal(t *testing.T) { func TestSubagentCancellationAfterRootFrozenCollectsNativeTerminal(t *testing.T) { s, f, out := observationSession(t, "inProgress") - s.emitDone("frozen", nil) + s.emitDoneAt("frozen", nil, nil) for i := 0; i < 4; i++ { select { case <-out: diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go index 905027d35..648e97071 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go @@ -15,9 +15,6 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay if !req.ObserveSubagentIdentities || req.DisableSubagents { return nil } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - return errors.New("codex: multi_agent with initialized tool environment requires verified child hook failure handling") - } for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { @@ -44,12 +41,8 @@ func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient, c if err != nil { return err } - // Packaged managed requirements may force hooks on. Only the immutable - // Bash pre-hook is admissible; managed-only discovery must survive reloads. - for _, hook := range hooks { - if !hook.isRuntimeToolEnvironmentHook() { - return errors.New("codex: subagent observation requires all hooks except the packaged Bash pre-hook disabled") - } + if len(hooks) != 0 { + return errors.New("codex: subagent observation requires hooks disabled") } raw, err := rpc.Request(ctx, "configRequirements/read", nil) diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go index 62613cbd7..fb903f2c3 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go @@ -26,8 +26,8 @@ func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { func TestSubagentProfileDoesNotDisableRequiredToolEnvironment(t *testing.T) { request := proto.PromptRequestPayload{ObserveSubagentIdentities: true, LocalEnvironment: &proto.LocalEnvironment{ToolEnvironment: true}} - if err := configureSubagentObservations(&SessionPlan{}, request); err == nil { - t.Fatal("incompatible hook profile accepted") + if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { + t.Fatal("ordinary environment must not require hooks", err) } request.DisableSubagents = true if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go new file mode 100644 index 000000000..49d409b2a --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go @@ -0,0 +1,61 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" +) + +// A native interrupted Turn can leave unified-exec terminals running. Retire +// their exact thread-owned handles; the bulk clean RPC only queues cleanup. +func (s *Session) cleanupNativeTerminals(ctx context.Context) error { + s.terminalCleanupMu.Lock() + defer s.terminalCleanupMu.Unlock() + thread := s.currentThreadID() + if thread == "" { + return nil + } + request := func(method string, params any) (json.RawMessage, error) { + return s.rpc.request(ctx, method, params, func(frame any) error { return s.rpc.writeFrameContext(ctx, frame) }) + } + seen := map[string]bool{} + for { + raw, err := request("thread/backgroundTerminals/list", map[string]any{"threadId": thread, "limit": 100}) + if err != nil { + return err + } + var page struct { + Data *[]struct { + ProcessID string `json:"processId"` + } `json:"data"` + NextCursor *string `json:"nextCursor"` + } + if json.Unmarshal(raw, &page) != nil || page.Data == nil { + return errors.New("codex: invalid native terminal list") + } + if len(*page.Data) == 0 { + if page.NextCursor != nil { + return errors.New("codex: incomplete native terminal list") + } + return nil + } + for _, terminal := range *page.Data { + if terminal.ProcessID == "" || seen[terminal.ProcessID] { + return errors.New("codex: invalid or retained native terminal identity") + } + seen[terminal.ProcessID] = true + raw, err = request("thread/backgroundTerminals/terminate", map[string]string{"threadId": thread, "processId": terminal.ProcessID}) + if err != nil { + return err + } + var receipt struct { + Terminated bool `json:"terminated"` + } + if json.Unmarshal(raw, &receipt) != nil || !receipt.Terminated { + return errors.New("codex: native terminal termination unconfirmed") + } + } + // Terminating the first page removes its handles. Read the first page + // again to confirm absence and cover any remaining native terminals. + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go new file mode 100644 index 000000000..ace5c1a17 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go @@ -0,0 +1,113 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { + for _, confirmed := range []bool{false, true} { + t.Run(map[bool]string{false: "retry", true: "confirmed"}[confirmed], func(t *testing.T) { + e, root := executorFixture(t, "terminal-cleanup") + allow := func() { + t.Helper() + if err := os.WriteFile(filepath.Join(root, "frames.jsonl.allow-cleanup"), nil, 0600); err != nil { + t.Fatal(err) + } + } + if confirmed { + allow() + } + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + ctx, stop := context.WithTimeout(t.Context(), 5*time.Second) + defer stop() + if err := turn.Cancel(ctx); (err == nil) != confirmed { + t.Fatalf("cancellation confirmed=%t: %v", confirmed, err) + } + settlement, err := turn.AwaitSettlement(ctx) + if (err == nil) != confirmed || settlement.Reusable != confirmed { + t.Fatal("cleanup changed cancellation confirmation", settlement, err) + } + for range out { + } + if !e.prepared.session.rpc.Alive() { + t.Fatal("native owner lost before cleanup/reuse") + } + frames := preparationFrames(t, root) + interrupted, terminated := false, false + for _, frame := range frames { + if frame.Method == "turn/interrupt" { + interrupted = true + } + if frame.Method == "thread/backgroundTerminals/terminate" { + if !interrupted { + t.Fatal("cleanup preceded native interrupt") + } + terminated = true + } + } + if !terminated { + t.Fatal("native interrupted receipt skipped terminal cleanup") + } + if confirmed { + nextOut := make(chan proto.Envelope, 20) + next, err := e.StartTurn(ctx, "next", proto.TextInput("answer"), nextOut) + if err != nil || !awaitExecutorTurn(t, next, nextOut).Reusable { + t.Fatal("confirmed cleanup prevented reuse", err) + } + } else { + if err := e.Close(ctx); err == nil || !e.prepared.session.rpc.Alive() { + t.Fatal("failed cleanup released the owned native process", err) + } + allow() + if err := e.Close(ctx); err != nil { + t.Fatal("cleanup retry failed", err) + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("resource cleanup fabricated native cancellation confirmation") + } + } + }) + } +} + +func TestExecutorCloseCleansNativeTerminalsAfterNormalCompletion(t *testing.T) { + e, root := executorFixture(t, "terminal-cleanup") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "normal", proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("normal Turn did not retain its Executor") + } + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/backgroundTerminals/terminate" { + t.Fatal("normal Turn prematurely killed its background terminal") + } + } + if err := os.WriteFile(filepath.Join(root, "frames.jsonl.allow-cleanup"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := e.Close(t.Context()); err != nil { + t.Fatal(err) + } + terminated := false + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/backgroundTerminals/terminate" { + terminated = true + } + } + if !terminated || e.prepared.session.rpc.Alive() { + t.Fatal("Executor Close left native terminals or owner alive") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go deleted file mode 100644 index 7af5efbd7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment.go +++ /dev/null @@ -1,97 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "time" -) - -const toolEnvironmentHookSource = "/etc/codex/runtime-hooks" -const toolEnvironmentHookCommand = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" - -func prepareSystemToolAnchor() error { - const anchor = "/tmp/oac-tool-root" - if err := os.Mkdir(anchor, 0500); err != nil && !errors.Is(err, os.ErrExist) { - return errors.New("codex: system tool temporary anchor unavailable") - } - actual, err := filepath.EvalSymlinks(anchor) - entries, readErr := os.ReadDir(anchor) - if err != nil || actual != anchor || readErr != nil || len(entries) != 0 { - return errors.New("codex: system tool temporary anchor is not an empty canonical directory") - } - return nil -} - -type nativeToolHook struct { - EventName string `json:"eventName"` - Command string `json:"command"` - Matcher string `json:"matcher"` - Enabled bool `json:"enabled"` - IsManaged bool `json:"isManaged"` - Async bool `json:"async"` - SourcePath string `json:"sourcePath"` - TrustStatus string `json:"trustStatus"` -} - -func (hook nativeToolHook) isRuntimeToolEnvironmentHook() bool { - return hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" -} - -func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) - if err != nil { - return nil, errors.New("codex: initialized tool hook unavailable") - } - var response struct { - Data []struct { - CWD string `json:"cwd"` - Hooks []nativeToolHook `json:"hooks"` - Errors []json.RawMessage `json:"errors"` - } `json:"data"` - } - if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { - return nil, errors.New("codex: initialized tool hook configuration unavailable") - } - return response.Data[0].Hooks, nil -} - -func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { - hooks, err := readNativeToolHooks(ctx, rpc, cwd) - if err != nil { - return err - } - for _, hook := range hooks { - if hook.isRuntimeToolEnvironmentHook() { - return nil - } - } - return errors.New("codex: required initialized tool hook missing") -} - -func (s *Session) onToolEnvironmentHook(raw json.RawMessage) { - if !s.toolEnvironment { - return - } - var notification struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Run struct { - SourcePath string `json:"sourcePath"` - Status string `json:"status"` - } `json:"run"` - } - if json.Unmarshal(raw, ¬ification) != nil || notification.Run.SourcePath != toolEnvironmentHookSource || !s.isRootTurn(notification.ThreadID, notification.TurnID) { - return - } - if notification.Run.Status == "failed" { - // Native hook process failures can run the original command before this - // notification. Stop subsequent work and report failure, never success. - s.emitTerminal("codex: initialized tool configuration failed; prior command effects may exist", true) - s.cancelFn() - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go deleted file mode 100644 index 376e27ed7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestInitializedToolHookReadiness(t *testing.T) { - for _, mode := range []string{"ready", "empty", "disabled", "unmanaged", "async", "wrong command", "post hook", "wrong matcher", "wrong source", "untrusted", "errors"} { - t.Run(mode, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - done := make(chan error, 1) - go func() { done <- verifyToolEnvironmentHook(ctx, client.JSONRPCClient, "/workspace") }() - var request struct { - ID, Method string - Params struct { - CWDs []string `json:"cwds"` - } - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "hooks/list" || len(request.Params.CWDs) != 1 || request.Params.CWDs[0] != "/workspace" { - t.Fatal("wrong hook lookup") - } - hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} - entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} - switch mode { - case "empty": - entry["hooks"] = []any{} - case "post hook": - hook["eventName"] = "postToolUse" - case "wrong matcher": - hook["matcher"] = ".*" - case "wrong source": - hook["sourcePath"] = "/workspace/hooks" - case "untrusted": - hook["trustStatus"] = "untrusted" - case "disabled": - hook["enabled"] = false - case "unmanaged": - hook["isManaged"] = false - case "async": - hook["async"] = true - case "wrong command": - hook["command"] = "/workspace/untrusted" - case "errors": - entry["errors"] = []any{"cannot load hook"} - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if err := <-done; (err == nil) != (mode == "ready") { - t.Fatal("hook admission", mode, err) - } - go func() { done <- verifySubagentObservationProfile(ctx, client.JSONRPCClient, "/workspace") }() - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if mode == "ready" || mode == "empty" { - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "configRequirements/read" { - t.Fatal(request.Method) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"requirements": map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}}}); err != nil { - t.Fatal(err) - } - } - - if err := <-done; (err == nil) != (mode == "ready" || mode == "empty") { - t.Fatal("subagent hook admission", mode, err) - } - - }) - } -} - -func TestInitializedToolHookFailureStopsRunWithoutSuccess(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - out := make(chan proto.Envelope, 4) - s := &Session{toolEnvironment: true, runID: "run", cancelCtx: ctx, cancelFn: cancel, out: out, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} - s.setThreadID("thread") - s.beginRootTurn("thread", "turn") - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"foreign","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if s.terminal.Load() { - t.Fatal("foreign hook ended run") - } - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"thread","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if !s.terminal.Load() || ctx.Err() == nil { - t.Fatal("failed hook did not stop execution") - } - if len(out) != 2 || (<-out).Type != proto.TypeError || (<-out).Type != proto.TypeDone { - t.Fatal("failed hook reported wrong terminal events") - } -} diff --git a/apps/parsar-daemon/internal/agent/executor.go b/apps/parsar-daemon/internal/agent/executor.go new file mode 100644 index 000000000..1e1346cf2 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/executor.go @@ -0,0 +1,61 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TurnSettlement describes native readiness after this Turn has fully drained. +// A false result must carry a reason; it requires confirmed Executor.Close. +type TurnSettlement struct { + Reusable bool + Reason string +} + +// Turn owns one output stream and never retargets cancellation to a successor. +type Turn interface { + Session + CancellationOutcome() proto.DonePayload + // Success confirms closed output and settled native input, function, + // interaction and child-work obligations. Errors cannot prove cancellation. + AwaitSettlement(context.Context) (TurnSettlement, error) +} + +// Executor retains a fixed native configuration across independently owned Turns. +type Executor interface { + // A nil Turn guarantees no input was submitted or output retained and leaves + // out with the caller. A non-nil Turn owns out, including on error; input + // may have been submitted and must never be replayed automatically. + StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Turn, error) + // Success confirms all native work and owned output streams have stopped. + // It does not establish an otherwise unknown Turn result. An error retains + // resource ownership; another caller may await or retry Close. + Close(context.Context) error +} + +// A failed factory retains any unconfirmed cleanup in its non-nil Executor. +type ExecutorFactory func(context.Context, proto.PromptRequestPayload) (Executor, error) + +func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { + r.mu.Lock() + defer r.mu.Unlock() + info, exists := r.kinds[kind] + if !exists || factory == nil { + panic("agent.Registry.RegisterExecutor: registered kind and factory required") + } + r.executors[kind] = factory + info.Capabilities.Preparation = true + r.kinds[kind] = info +} + +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 +} diff --git a/apps/parsar-daemon/internal/agent/installation.go b/apps/parsar-daemon/internal/agent/installation.go new file mode 100644 index 000000000..2083b8052 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installation.go @@ -0,0 +1,14 @@ +package agent + +import "context" + +// Installation is an optional adapter-owned native distribution contract. +// It supplies activation paths and readiness checks, never execution or model +// configuration. Runtime owns copying, checksums, publication and installation locks. +type Installation struct { + AgentKind string + Version string + Supported func() bool + Environment func(directory, node string) map[string]string + Check func(context.Context, string, string, []string) error +} diff --git a/apps/parsar-daemon/internal/agent/installroot/probe.go b/apps/parsar-daemon/internal/agent/installroot/probe.go new file mode 100644 index 000000000..b0a12fc3d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installroot/probe.go @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..420155530 --- /dev/null +++ b/apps/parsar-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-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/environment_mcp.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go index c69396f3d..7fc33a999 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go @@ -31,7 +31,7 @@ func environmentMCP(local *proto.LocalEnvironment) ([]map[string]any, error) { } command, args := localworkspace.MCPStdioCommand(item) // The fixed launcher resolves the declaration and selected user variables - // inside the sandbox. Native process variables are never tool input. + // from the installed snapshot in the shared Runtime launcher. servers = append(servers, map[string]any{"name": name, "command": command, "args": args, "env": []map[string]string{}}) } return servers, nil diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index a47e4eb1c..3b22c5517 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -15,7 +15,7 @@ import ( ) func environmentMCPFixture() proto.EnvironmentMCP { - return proto.EnvironmentMCP{PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ + return proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ Name: "proof.server", Type: "stdio", Command: "never-exec-before-sandbox", Args: []string{"private-argument"}, EnvVars: []string{"USER_SELECTED"}, CWD: "resources", }} @@ -53,9 +53,10 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { t.Fatal("environment MCP displaced workspace tools") } server := params.MCP[1] - if server.Name != "proof.server" || server.Command != "/usr/bin/python3" || server.Env == nil || len(server.Env) != 0 || - !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "plugins/fixture", "proof.server"}) { - t.Fatal("ACP declaration bypassed the fixed isolated launcher") + executable, _ := os.Executable() + if server.Name != "proof.server" || server.Command != executable || server.Env == nil || len(server.Env) != 0 || + !reflect.DeepEqual(server.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/fixture", "proof.server"}) { + t.Fatal("ACP declaration bypassed the shared Runtime launcher") } if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { t.Fatal("native attachment changed identity or exposed private inputs") @@ -114,7 +115,7 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = session.Cancel(context.Background()) }) + t.Cleanup(func() { _ = resource.(*prepared).Cancel(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -125,7 +126,11 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { t.Fatal("MCP start was not observed") } if err := session.Cancel(ctx); err != nil { - t.Fatal(err) + t.Fatal("stopped nonreusable MCP owner reported cancellation failure", err) + } + settlement, err := session.(*Session).AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal("pending MCP work retained a reusable owner", settlement, err) } closed, done := false, false for event := range out { diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index b8c94bbed..4d2987310 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -13,7 +13,7 @@ func SupportsExecution(version string) bool { } func validateExecutionRequest(req proto.PromptRequestPayload) error { - if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + if !req.DisableExecutionEnvironment || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { return fmt.Errorf("mcode: unsupported execution configuration") } if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { @@ -48,16 +48,8 @@ func configureTextExecution(config map[string]any) { config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} } -// Only process and model-network essentials cross into the native child. -func executionEnvironment() []string { - var env []string - for _, key := range []string{"PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"} { - if value, ok := os.LookupEnv(key); ok { - env = append(env, key+"="+value) - } - } - return env -} +// Harness children use the daemon user's ordinary environment. +func executionEnvironment() []string { return os.Environ() } // ACP commands are only recognized for a single text block. Public input must // remain user text; ordinary product Sessions retain their native command behavior. diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/parsar-daemon/internal/agent/mcode/execution_test.go index 3e704750e..083897f94 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution_test.go @@ -17,7 +17,7 @@ func executionRequest(t *testing.T) proto.PromptRequestPayload { return r } -func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { +func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { r := executionRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") @@ -25,11 +25,10 @@ func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { if err != nil { t.Fatal(err) } - for _, e := range opts.Env { - if strings.HasPrefix(e, "OAC_TEST_SECRET_CANARY=") || strings.HasPrefix(e, "NODE_OPTIONS=") { - t.Fatal("ambient authority inherited") - } + if !strings.Contains(strings.Join(opts.Env, "\n"), "OAC_TEST_SECRET_CANARY=secret") { + t.Fatal("user environment lost") } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go new file mode 100644 index 000000000..cfe83cdc1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor.go @@ -0,0 +1,176 @@ +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_backpressure_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go new file mode 100644 index 000000000..f2f78c6df --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go @@ -0,0 +1,99 @@ +package mcode + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorBlockedPromptWriteCanBeCancelledOrClosed(t *testing.T) { + for _, operation := range []string{"cancel", "close"} { + t.Run(operation, func(t *testing.T) { + e, _ := executorFixture(t, "executor-backpressure", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "blocked", proto.TextInput(strings.Repeat("x", 4*1024*1024)), out) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for e.connection.writeMu.TryLock() { + e.connection.writeMu.Unlock() + if time.Now().After(deadline) { + t.Fatal("prompt did not reach native pipe") + } + time.Sleep(time.Millisecond) + } + // The native fixture acknowledged readiness but does not read stdin. + // A multi-megabyte prompt therefore fills the actual child process pipe. + result := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + if operation == "cancel" { + result <- turn.Cancel(ctx) + } else { + result <- e.Close(ctx) + } + }() + select { + case err := <-result: + if operation == "cancel" && err == nil { + t.Fatal("unacknowledged cancellation was reported applied") + } + case <-time.After(3 * time.Second): + // Emergency cleanup avoids leaking the intentionally blocked fixture. + e.connection.process.Cancel() + t.Fatal(operation + " could not interrupt a blocked native write") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Fatal("Close did not retain cleanup ownership", err) + } + settlement, err := turn.AwaitSettlement(ctx) + if err == nil || settlement.Reusable { + t.Fatal("lost native prompt produced successful settlement", settlement, err) + } + for range out { + } + }) + } +} + +func TestExecutorUnknownSteeringOutcomeCannotBecomeAppliedCancellation(t *testing.T) { + e, _ := executorFixture(t, "executor-steer-unknown", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "unknown-input", proto.TextInput("wait"), out) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + select { + case <-out: + case <-ctx.Done(): + t.Fatal("native prompt did not start") + } + session := turn.(*Session) + if err := session.Steer(ctx, proto.PromptSteerPayload{InputID: "input-unknown", Input: proto.TextInput("continue")}); err == nil { + t.Fatal("unknown native receipt was accepted") + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("unknown input became applied cancellation") + } + settlement, err := turn.AwaitSettlement(ctx) + if err == nil || settlement.Reusable { + t.Fatal("unknown input lost its settlement error", settlement, err) + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("repeated cancel lost original uncertainty") + } + if err := e.Close(ctx); err != nil { + t.Fatal("settlement failure prevented resource cleanup", err) + } + for range out { + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go new file mode 100644 index 000000000..5340c3f9f --- /dev/null +++ b/apps/parsar-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-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 new file mode 100644 index 000000000..2edf8eecd --- /dev/null +++ b/apps/parsar-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-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 new file mode 100644 index 000000000..ed81a3cc6 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go @@ -0,0 +1,151 @@ +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/installation.go b/apps/parsar-daemon/internal/agent/mcode/installation.go new file mode 100644 index 000000000..eff3a77e7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/installation.go @@ -0,0 +1,28 @@ +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/preparation.go b/apps/parsar-daemon/internal/agent/mcode/preparation.go index 97e614d82..8d9672548 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation.go @@ -3,145 +3,85 @@ package mcode import ( "context" "fmt" - "strings" "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 - session *Session - ready chan struct{} - started chan struct{} - failure error - closed bool - binding *preparedStart -} - -type preparedStart struct { - runID string - prompt proto.MessageInput - out chan<- proto.Envelope + 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) { - if ctx == nil { - ctx = context.Background() - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("mcode: preparation cannot contain input or product context") - } - opts, err := prepareWorkspaceOptions(ctx, config, req) - if err != nil { - return nil, err - } - s, err := launch(ctx, req, opts, config.Binary, nil) + value, err := factory(ctx, req) if err != nil { + if value != nil { + return &prepared{executor: value.(*executor)}, err + } return nil, err } - p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} - go s.run(p) - <-p.ready - if p.failure != nil { - <-s.finished - return nil, p.failure - } - if ctx.Err() != nil { - _ = p.Close() - return nil, ctx.Err() - } - return p, nil + 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, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } +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.binding != nil { + if p.closed || p.started { return nil, fmt.Errorf("mcode: preparation is no longer available") } - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil || out == nil || ctx.Err() != nil { - return nil, fmt.Errorf("mcode: start requires a live context, identity, prompt and output") - } - select { - case <-p.session.process.Context().Done(): - return nil, fmt.Errorf("mcode: prepared process ended") - case <-p.session.exited: - return nil, fmt.Errorf("mcode: prepared process exited") - default: + turn, err := p.executor.StartTurn(ctx, runID, input, out) + if turn != nil { + p.started, p.session = true, turn.(*Session) } - p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} - close(p.started) - return p.session, nil + return turn, err } func (p *prepared) Close() error { p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil + started := p.started + if !started { + p.closed = true } - p.closed = true p.mu.Unlock() - return p.closeUnused(context.Background()) + if started { + return nil + } + return p.executor.Close(context.Background()) } func (p *prepared) Cancel(ctx context.Context) error { p.mu.Lock() p.closed = true - started := p.binding != nil + current, started := p.session, p.started p.mu.Unlock() - if !started { - return p.closeUnused(ctx) - } - return p.session.Cancel(ctx) -} - -// An unconsumed preparation has no model input or child work to settle. -func (p *prepared) closeUnused(ctx context.Context) error { - p.session.process.Cancel() - select { - case <-p.session.finished: - return nil - case <-ctx.Done(): - return ctx.Err() + 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 { return p.session.CancellationOutcome() } - -// The Session goroutine owns preparation, input submission and terminal output. -func (p *prepared) awaitStart(err error) error { - p.failure = err - close(p.ready) - if err != nil { - return err - } - for { - var ended error - select { - case <-p.started: - case <-p.session.process.Context().Done(): - ended = p.session.process.Context().Err() - case _, ok := <-p.session.frames: - if ok { - continue - } - ended = fmt.Errorf("mcode: prepared process exited") - } - p.mu.Lock() - if b := p.binding; b != nil { - p.session.req.RunID, p.session.req.Input, p.session.out = b.runID, b.prompt, b.out - } else { - p.closed = true - } - p.mu.Unlock() - return ended +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 index 9ae612c19..4fe282689 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -18,7 +18,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload r := executionRequest(t) r.RunID, r.Input, r.ConversationID = "", nil, "" r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "disabled"} + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} r.WorkDir = t.TempDir() record := filepath.Join(t.TempDir(), "calls") exe, err := os.Executable() @@ -31,7 +31,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "disabled", Scratch: t.TempDir(), ProtectedDirs: []string{os.Getenv("OAC_RUNTIME_HOME")}}, r, record + 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) { @@ -88,8 +88,8 @@ func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { done++ select { case <-p.session.exited: + t.Fatal("successful Turn disposed the reusable native owner") default: - t.Fatal("Done before native settlement") } var d proto.DonePayload _ = json.Unmarshal(e.Payload, &d) diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index 476ace40b..e65948f08 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -1,15 +1,12 @@ package mcode import ( - "bufio" "context" "encoding/json" "errors" "fmt" - "io" "net/url" "slices" - "strconv" "strings" "sync" "time" @@ -20,22 +17,30 @@ import ( ) type Session struct { - ctx context.Context - req proto.PromptRequestPayload - opts launchOptions - process *clirunner.Process + 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 - exited chan struct{} finished chan struct{} - writeMu sync.Mutex mu sync.Mutex sessionID string + nativeModel string + outputContext context.Context + outcome proto.DonePayload permissions map[string]pendingPermission questions map[string]pendingQuestion - exitErr error - nextID int - responses map[string]chan rpcFrame steeringReady bool steeringTurn string sequence uint64 @@ -70,7 +75,7 @@ func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- if err != nil { return nil, err } - go s.run(nil) + go s.run() return s, nil } @@ -79,56 +84,18 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti if err != nil { return nil, err } - s := &Session{ctx: ctx, req: req, opts: opts, process: process, out: out, frames: make(chan rpcFrame, 32), exited: make(chan struct{}), finished: make(chan struct{}), responses: map[string]chan rpcFrame{}, permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} - go func() { _, _ = io.Copy(io.Discard, process.Stderr) }() - go s.read() + 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 (s *Session) read() { - defer close(s.exited) - defer close(s.frames) - scanner := bufio.NewScanner(s.process.Stdout) - scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - var readErr error - for scanner.Scan() { - var frame rpcFrame - if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { - readErr = fmt.Errorf("mcode: malformed ACP response") - s.process.Cancel() - break - } - if frame.Method == "" { - s.mu.Lock() - response := s.responses[string(frame.ID)] - s.mu.Unlock() - if response != nil { - select { - case response <- frame: - default: - } - continue - } - } - select { - case s.frames <- frame: - case <-s.finished: - case <-s.process.Context().Done(): - } - } - if scanner.Err() != nil { - readErr = fmt.Errorf("mcode: ACP stream read failed") - s.process.Cancel() - } - waitErr := s.process.Wait() - if readErr != nil { - s.exitErr = readErr - } else { - s.exitErr = waitErr - } +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(p *prepared) { +func (s *Session) run() { defer func() { if s.out != nil { close(s.out) @@ -136,9 +103,6 @@ func (s *Session) run(p *prepared) { }() defer close(s.finished) err := s.prepareNative() - if p != nil { - err = p.awaitStart(err) - } if err == nil { if s.req.StrictResume && !s.req.DisableSubagents { var snapshot nativeSubagentSnapshot @@ -166,7 +130,7 @@ func (s *Session) run(p *prepared) { err = observationErr } } - if p != nil || err != nil { + if err != nil { s.process.Cancel() <-s.exited } @@ -241,6 +205,7 @@ func (s *Session) prepareNative() error { 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 } @@ -264,6 +229,12 @@ func (s *Session) executePrompt() error { 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) } @@ -304,7 +275,26 @@ func (s *Session) call(method string, params any, result any, prompt bool) error if err != nil { return err } - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}); err != nil { + 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() @@ -317,6 +307,8 @@ func (s *Session) call(method string, params any, result any, prompt bool) error 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 @@ -344,24 +336,30 @@ func (s *Session) call(method string, params any, result any, prompt bool) error } } -func (s *Session) write(frame rpcFrame) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return json.NewEncoder(s.process.Stdin).Encode(frame) -} - 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: - case <-s.ctx.Done(): + 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() @@ -409,18 +407,3 @@ func (s *Session) SubmitPermission(_ context.Context, id string, decision proto. delete(s.permissions, id) return nil } - -func (s *Session) reserveResponse() (string, chan rpcFrame) { - s.mu.Lock() - defer s.mu.Unlock() - s.nextID++ - id := strconv.Itoa(s.nextID) - reply := make(chan rpcFrame, 1) - s.responses[id] = reply - return id, reply -} -func (s *Session) removeResponse(id string) { - s.mu.Lock() - delete(s.responses, id) - s.mu.Unlock() -} diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 2e347057a..47f480c28 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -198,6 +198,7 @@ func TestMCodeProcess(t *testing.T) { } 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 { @@ -248,6 +249,15 @@ func TestMCodeProcess(t *testing.T) { 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:" { @@ -258,10 +268,33 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || strings.HasPrefix(scenario, "prepared"); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + 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 } @@ -294,7 +327,17 @@ func TestMCodeProcess(t *testing.T) { } 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) } diff --git a/apps/parsar-daemon/internal/agent/mcode/steering.go b/apps/parsar-daemon/internal/agent/mcode/steering.go index 7479b2964..cb16925bc 100644 --- a/apps/parsar-daemon/internal/agent/mcode/steering.go +++ b/apps/parsar-daemon/internal/agent/mcode/steering.go @@ -18,6 +18,16 @@ func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) err // Native acceptance belongs to the active ACP Turn; it does not promise model consumption. func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if s.executor != nil { + s.mu.Lock() + if s.closing || s.cancelled { + s.mu.Unlock() + return agent.ErrSteeringInactive + } + s.operations.Add(1) + s.mu.Unlock() + defer s.operations.Done() + } text, err := input.Input.TextOnly() if strings.TrimSpace(input.InputID) == "" || err != nil { return agent.ErrSteeringRejected @@ -40,10 +50,9 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP if err := ctx.Err(); err != nil { return err } - stop := context.AfterFunc(ctx, s.process.Cancel) - err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) - stop() + 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 { @@ -53,7 +62,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP var stopped error select { case frame = <-response: - case <-s.finished: + case <-s.inputSettlementDone(): stopped = fmt.Errorf("mcode: run ended with unknown input outcome") case <-s.exited: stopped = fmt.Errorf("mcode: input transport closed") @@ -65,6 +74,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP select { case frame = <-response: default: + s.markInputUncertain() return stopped } } @@ -72,6 +82,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP 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 { @@ -79,23 +90,41 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP 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/subagent_cancel.go b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go index 1469c1ff0..bd1b88e68 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go @@ -7,6 +7,21 @@ import ( ) func (s *Session) cancelSubagents(ctx context.Context) error { + if err := s.stopSubagents(ctx); err != nil { + return err + } + select { + case <-s.finished: + s.mu.Lock() + err := s.subagentSettlementError + s.mu.Unlock() + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) stopSubagents(ctx context.Context) error { select { case <-s.finished: return nil @@ -21,7 +36,7 @@ func (s *Session) cancelSubagents(ctx context.Context) error { id, response := s.reserveResponse() defer s.removeResponse(id) raw, _ := json.Marshal(map[string]string{"sessionId": native}) - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/delegation/stop", Params: raw}); err != nil { + if err := s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/delegation/stop", Params: raw}); err != nil { return err } select { @@ -40,13 +55,5 @@ func (s *Session) cancelSubagents(ctx context.Context) error { case <-ctx.Done(): return ctx.Err() } - select { - case <-s.finished: - s.mu.Lock() - err := s.subagentSettlementError - s.mu.Unlock() - return err - case <-ctx.Done(): - return ctx.Err() - } + return nil } diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents.go b/apps/parsar-daemon/internal/agent/mcode/subagents.go index 33fe2ed9e..645525f54 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagents.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagents.go @@ -121,11 +121,9 @@ func (s *Session) settleSubagents() error { settled = false } } - if session.ID != snapshot.Root { - for _, turn := range session.Turns { - if turn.Status == "accepted" { - settled = false - } + for _, turn := range session.Turns { + if turn.Status == "accepted" { + settled = false } } } diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go index de4b4b414..9268c5837 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go @@ -3,8 +3,11 @@ package mcode import ( "context" "encoding/json" + "os" + "path/filepath" "reflect" "testing" + "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -65,3 +68,35 @@ func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { 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 new file mode 100644 index 000000000..420cdf980 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go @@ -0,0 +1,40 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSelfHostedToolEnvironment(t *testing.T) { + config, req, _ := workspaceFixture(t) + 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) + opts, err := prepareWorkspaceOptions(t.Context(), config, req) + 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 { + ToolEnv map[string]string `json:"toolEnv"` + } + if err := json.Unmarshal(raw, &profile); 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 := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + t.Fatal("invalid explicit tool configuration was ignored") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 88864e976..5a5e1e327 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -6,8 +6,7 @@ import ( "fmt" "os" "path/filepath" - "slices" - "strings" + "runtime" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -17,33 +16,34 @@ import ( // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { Binary, Node, Bridge, Directory, Network, Scratch string - ProtectedDirs []string AllowedDomains []string } -func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { +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"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), filepath.Join(root, "runtime", "mcode"), filepath.Dir(workspace), staging}} + 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, staging} { + 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} { - resolved, err := filepath.EvalSymlinks(path) - if err != nil || resolved != path || strings.HasPrefix(path, workspace+"/") || strings.HasPrefix(path, "/workspace/") { - return c, fmt.Errorf("mcode: runtime programs must be canonical paths outside the workspace") + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return c, fmt.Errorf("mcode: runtime program unavailable") } } bound, err := os.Stat(workspace) - public, publicErr := os.Stat("/workspace") - if err != nil || publicErr != nil || !bound.IsDir() || !os.SameFile(bound, public) { - return c, fmt.Errorf("mcode: public workspace alias does not match the Runtime binding") + 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 } @@ -51,6 +51,9 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } 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") } @@ -103,18 +106,17 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} - if req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + 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 + { + values, err := localworkspace.ReadOptionalToolEnvironment() + if err != nil { return opts, err } - profile["toolEnvironment"] = true - profile["systemPackages"] = req.LocalEnvironment.SystemPackages - // Initialization exposes only user env/packages; private staging and - // daemon/native history remain explicitly denied. - protected := slices.Clone(c.ProtectedDirs) - profile["protectedDirs"] = slices.DeleteFunc(protected, func(path string) bool { return path == "/environment" }) + profile["toolEnv"] = values } + raw, err = json.Marshal(profile) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go index 199e6e857..a42d387cd 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go @@ -1,38 +1,14 @@ package mcode -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "testing" -) +import "testing" -func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { - config, req, _ := workspaceFixture(t) - config.Network = "restricted" - config.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} - req.LocalEnvironment.NetworkAccess = "restricted" - req.LocalEnvironment.AllowedDomains = []string{"api.example.com", "EXAMPLE.COM"} - opts, err := prepareWorkspaceOptions(t.Context(), config, req) - 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 { - Network string `json:"network"` - Domains []string `json:"allowedDomains"` - } - if err := json.Unmarshal(raw, &profile); err != nil || profile.Network != "restricted" || !slices.Equal(profile.Domains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost bound authority", profile, err) - } - for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { - req.LocalEnvironment.AllowedDomains = domains - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { - t.Fatal("different policy entered bound Runtime", domains) +func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { + for _, access := range []string{"disabled", "restricted"} { + c, req, _ := workspaceFixture(t) + c.Network = access + req.LocalEnvironment.NetworkAccess = access + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + t.Fatal("unsupported network isolation accepted") } } } diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go index 84b71f218..85d1b4535 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go @@ -27,11 +27,20 @@ func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { p.Cancel() } waitErr := p.Wait() + if err != nil || waitErr != nil { + return fmt.Errorf("mcode: workspace companion check failed") + } + return ValidateWorkspaceReadiness(raw) +} + +// ValidateWorkspaceReadiness checks the installed companion's private contract. +// Neither upstream version alone nor a successful CLI --version proves cleanup. +func ValidateWorkspaceReadiness(raw []byte) error { var info struct { Protocol int `json:"protocol"` Native, Source string } - if err != nil || waitErr != nil || len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 1 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { + if len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 2 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { return fmt.Errorf("mcode: workspace companion check failed") } return nil diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go new file mode 100644 index 000000000..6fca32fe8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go @@ -0,0 +1,42 @@ +package mcode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("MiniMax adapter supports Linux and macOS") + } + for _, protocol := range []int{1, 2} { + t.Run(fmt.Sprint(protocol), func(t *testing.T) { + dir := t.TempDir() + node := filepath.Join(dir, "node") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' '{\"protocol\":%d,\"native\":\"0.4.12\",\"source\":\"33b259bbbeb1c16433390869938191d09bdb0680\"}'\n", protocol) + if err := os.WriteFile(node, []byte(body), 0700); err != nil { + t.Fatal(err) + } + err := CheckWorkspace(context.Background(), WorkspaceConfig{Node: node, Bridge: filepath.Join(dir, "bridge.mjs")}) + if (err == nil) != (protocol == 2) { + t.Fatalf("protocol %d readiness: %v", protocol, err) + } + }) + } +} + +func TestValidateWorkspaceReadinessRejectsInvalidDescriptors(t *testing.T) { + for _, raw := range []string{"", "null", "{", strings.Repeat(" ", 4097), + `{"protocol":1,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":2,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":2,"native":"0.4.12","source":"other"}`} { + if err := ValidateWorkspaceReadiness([]byte(raw)); err == nil || err.Error() != "mcode: workspace companion check failed" { + t.Fatal("invalid descriptor accepted or exposed", err) + } + } +} diff --git a/apps/parsar-daemon/internal/agent/registry.go b/apps/parsar-daemon/internal/agent/registry.go index d9f6459f5..97d79a4c9 100644 --- a/apps/parsar-daemon/internal/agent/registry.go +++ b/apps/parsar-daemon/internal/agent/registry.go @@ -58,6 +58,7 @@ type Registry struct { mu sync.RWMutex factories map[string]Factory preparers map[string]PreparationFactory + executors map[string]ExecutorFactory kinds map[string]proto.SupportedAgentKind } @@ -65,6 +66,7 @@ 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), } } @@ -90,6 +92,7 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, f Factory) { defer r.mu.Unlock() r.factories[kind] = f delete(r.preparers, kind) + delete(r.executors, kind) info.Capabilities.Preparation = false info.Capabilities.WorkspaceReadPreparation = false r.kinds[kind] = info diff --git a/apps/parsar-daemon/internal/agent/registry_test.go b/apps/parsar-daemon/internal/agent/registry_test.go index 58c4488a4..e1ab3e8f7 100644 --- a/apps/parsar-daemon/internal/agent/registry_test.go +++ b/apps/parsar-daemon/internal/agent/registry_test.go @@ -136,3 +136,24 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { t.Fatalf("opencode descriptor not preserved: %#v", got[1]) } } + +func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { + registry := agent.NewRegistry() + registry.Register("native", 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.Register("native", stubFactory("replacement")) + if _, err := registry.ResolveExecutor("native"); err == nil { + t.Fatal("replacing a kind retained its old executor capability") + } +} diff --git a/apps/parsar-daemon/internal/auth/store.go b/apps/parsar-daemon/internal/auth/store.go index 910e63305..1dea39040 100644 --- a/apps/parsar-daemon/internal/auth/store.go +++ b/apps/parsar-daemon/internal/auth/store.go @@ -11,10 +11,10 @@ import ( "errors" "fmt" "os" - "path/filepath" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // Profile is the on-disk representation of one paired credential. @@ -68,21 +68,22 @@ func Save(profile string, p Profile) error { if err != nil { return err } - authFile := filepath.Join(dir, "auth.json") - tmp := authFile + ".tmp" raw, err := json.MarshalIndent(p, "", " ") if err != nil { - return fmt.Errorf("auth: marshal profile: %w", err) + return errors.New("auth: could not encode profile") } - // os.WriteFile with the final mode in one shot, so umask is - // irrelevant. - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return fmt.Errorf("auth: write tmp: %w", err) + if err = runtimefs.EnsurePrivateDir(dir); err != nil { + return errors.New("auth: private profile unavailable") } - if err := os.Rename(tmp, authFile); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("auth: rename: %w", err) + held, err := os.OpenRoot(dir) + if err != nil { + return errors.New("auth: private profile unavailable") + } + defer held.Close() + if err = runtimefs.WritePrivateAtomic(held, "auth.json", raw); err != nil { + return errors.New("auth: private profile write failed") } + return nil } diff --git a/apps/parsar-daemon/internal/auth/store_test.go b/apps/parsar-daemon/internal/auth/store_test.go index 0f87ba2ae..f9d28e404 100644 --- a/apps/parsar-daemon/internal/auth/store_test.go +++ b/apps/parsar-daemon/internal/auth/store_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "testing" "time" @@ -60,9 +61,9 @@ func TestSaveSetsRestrictivePerms(t *testing.T) { if err != nil { t.Fatalf("stat auth.json: %v", err) } - // auth.json holds the long-lived runner_credential — anything - // but 0600 leaks it on a shared CI box. - if mode := info.Mode().Perm(); mode != 0o600 { + // Unix stores credentials with 0600; Windows uses normal account ACLs, + // which are not represented by Go permission bits. + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("auth.json perm = %o, want 0600", mode) } } @@ -109,7 +110,7 @@ func TestSaveOverwritesAndHealsPerms(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } - if mode := info.Mode().Perm(); mode != 0o600 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode) } got, err := auth.Load("default") diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index ad727c627..59b5c183d 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -16,6 +16,7 @@ import ( // agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. type agentCLIDiscovery struct { + installedKinds map[string]bool MCodeWorkspace *mcode.WorkspaceConfig ClaudeSDK *claudeSDKDiscovery ClaudeCode proto.SupportedAgentKind @@ -25,6 +26,10 @@ type agentCLIDiscovery struct { MCode proto.SupportedAgentKind } +func (d agentCLIDiscovery) permits(kind string) bool { + return d.installedKinds == nil || d.installedKinds[kind] +} + type agentCLIChecks struct { ClaudeSDK func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) ClaudeCode func(context.Context, string) (string, error) @@ -44,11 +49,11 @@ func defaultAgentCLIChecks() agentCLIChecks { } } -func preflightAgentCLIs(rc *runContext, profile string) (agentCLIDiscovery, error) { - return discoverAgentCLIs(rc, profile, defaultAgentCLIChecks()) +func preflightAgentCLIs(parent context.Context, rc *runContext, profile string) (agentCLIDiscovery, error) { + return discoverAgentCLIs(parent, rc, profile, defaultAgentCLIChecks()) } -func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { +func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { if checks.ClaudeCode == nil { checks.ClaudeCode = claudecode.CheckCLIAvailable } @@ -62,6 +67,7 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a checks.Pi = pi.CheckCLIAvailable } out := agentCLIDiscovery{ + installedKinds: rc.installedKinds, ClaudeCode: proto.SupportedAgentKind{ Kind: "claude_code", Capabilities: proto.AgentKindCapabilities{ @@ -117,74 +123,88 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a }, } - claudeCtx, cancelClaude := context.WithTimeout(context.Background(), cliVersionTimeout) - claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") - cancelClaude() - if claudeErr == nil { - out.ClaudeCode.Available = true - out.ClaudeCode.Version = claudeVersion - fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) - } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) + if out.permits("claude_code") { + claudeCtx, cancelClaude := context.WithTimeout(parent, cliVersionTimeout) + claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") + cancelClaude() + if claudeErr == nil { + out.ClaudeCode.Available = true + out.ClaudeCode.Version = claudeVersion + fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) + } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) + } } - opencodeCtx, cancelOpenCode := context.WithTimeout(context.Background(), cliVersionTimeout) - opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") - cancelOpenCode() - if opencodeErr == nil { - out.OpenCode.Available = true - out.OpenCode.Version = opencodeVersion - fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) - } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: OpenCode CLI not found on PATH; opencode unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + if out.permits("opencode") { + opencodeCtx, cancelOpenCode := context.WithTimeout(parent, cliVersionTimeout) + opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") + cancelOpenCode() + if opencodeErr == nil { + out.OpenCode.Available = true + out.OpenCode.Version = opencodeVersion + fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) + } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: OpenCode CLI not found on PATH; opencode unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + } } - codexCtx, cancelCodex := context.WithTimeout(context.Background(), cliVersionTimeout) - codexVersion, codexErr := checks.Codex(codexCtx, "") - cancelCodex() - if codexErr == nil { - out.Codex.Available = true - out.Codex.Version = codexVersion - out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) - out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) - out.Codex.Capabilities.LocalEnvironmentNetworkPolicy = codex.SupportsLocalNetworkPolicy(codexVersion) - out.Codex.Capabilities.MCPHTTPRequired = codex.SupportsNativeSessionRecovery(codexVersion) - fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) - } else if errors.Is(codexErr, codex.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Codex CLI not found on PATH; codex unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) + if out.permits("codex") { + codexCtx, cancelCodex := context.WithTimeout(parent, cliVersionTimeout) + codexVersion, codexErr := checks.Codex(codexCtx, "") + cancelCodex() + if codexErr == nil { + out.Codex.Available = true + out.Codex.Version = codexVersion + out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) + out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) + out.Codex.Capabilities.MCPHTTPRequired = codex.SupportsNativeSessionRecovery(codexVersion) + fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) + } else if errors.Is(codexErr, codex.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: Codex CLI not found on PATH; codex unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) + } } - piCtx, cancelPi := context.WithTimeout(context.Background(), cliVersionTimeout) - piVersion, piErr := checks.Pi(piCtx, "") - cancelPi() - if piErr == nil { - out.Pi.Available = true - out.Pi.Version = piVersion - fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) - } else if errors.Is(piErr, pi.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: pi CLI not found on PATH; pi unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) + if out.permits("pi") { + piCtx, cancelPi := context.WithTimeout(parent, cliVersionTimeout) + piVersion, piErr := checks.Pi(piCtx, "") + cancelPi() + if piErr == nil { + out.Pi.Available = true + out.Pi.Version = piVersion + fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) + } else if errors.Is(piErr, pi.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: pi CLI not found on PATH; pi unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) + } } - out.MCode = discoverMCode(rc, checks.MCode) - discoverMCodeWorkspace(rc, &out) - out.ClaudeSDK = discoverClaudeSDK(rc, profile, checks.ClaudeSDK) + if out.permits("mcode") { + out.MCode = discoverMCode(parent, rc, checks.MCode) + discoverMCodeWorkspace(parent, rc, &out) + } + if out.permits("claude_sdk") { + out.ClaudeSDK = discoverClaudeSDK(parent, rc, profile, checks.ClaudeSDK) + } + if err := parent.Err(); err != nil { + return out, err + } if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available && !out.MCode.Available && (out.ClaudeSDK == nil || !out.ClaudeSDK.Info.Available) { return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or mcode, or configure a Claude SDK runtime)") } diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/parsar-daemon/internal/cli/agent_registration.go index 17126ff5a..eb3bf55d8 100644 --- a/apps/parsar-daemon/internal/cli/agent_registration.go +++ b/apps/parsar-daemon/internal/cli/agent_registration.go @@ -13,9 +13,18 @@ import ( ) func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, serverURL string) { - registerProductAgentKind(registry, agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) - registerProductAgentKind(registry, agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) - registerProductAgentKind(registry, agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) + registerProduct := func(info proto.SupportedAgentKind, factory agent.Factory) { + if agentCLIs.permits(info.Kind) { + registerProductAgentKind(registry, info, factory) + } + } + + registerProduct(agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) + if agentCLIs.Codex.Available { + registry.RegisterExecutor("codex", withExecutorCapabilities(codex.NewExecutorFactory(), serverURL)) + } if agentCLIs.Codex.Available && agentCLIs.Codex.Capabilities.LocalEnvironment { registry.RegisterPreparation("codex", true, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { prepared, err := codex.Prepare(ctx, req) @@ -25,12 +34,15 @@ func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, s return prepared, err }) } - registerProductAgentKind(registry, agentCLIs.Pi, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.Pi, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) if agentCLIs.MCodeWorkspace != nil { registry.RegisterKind(agentCLIs.MCode, mcode.Factory) registry.RegisterPreparation("mcode", true, mcode.NewPreparationFactory(*agentCLIs.MCodeWorkspace)) } else { - registerProductAgentKind(registry, agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) + } + if agentCLIs.MCode.Available { + registry.RegisterExecutor("mcode", withExecutorCapabilities(mcode.NewExecutorFactory(agentCLIs.MCodeWorkspace), serverURL)) } registerClaudeSDK(registry, agentCLIs.ClaudeSDK) } diff --git a/apps/parsar-daemon/internal/cli/authoring.go b/apps/parsar-daemon/internal/cli/authoring.go index b6b456906..e8fb938ce 100644 --- a/apps/parsar-daemon/internal/cli/authoring.go +++ b/apps/parsar-daemon/internal/cli/authoring.go @@ -62,6 +62,9 @@ func authoringRegistry(registry *agent.Registry, bridge *authoring.Bridge) *agen factory = withAuthoringBridge(factory, bridge) } wrapped.RegisterKind(info, factory) + if executor, err := registry.ResolveExecutor(info.Kind); err == nil { + wrapped.RegisterExecutor(info.Kind, executor) + } if prepare, err := registry.ResolvePreparation(info.Kind); err == nil { wrapped.RegisterPreparation(info.Kind, info.Capabilities.WorkspaceReadPreparation, prepare) } diff --git a/apps/parsar-daemon/internal/cli/capability_downloads.go b/apps/parsar-daemon/internal/cli/capability_downloads.go index 2f5e95819..e92fdfc0c 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads.go +++ b/apps/parsar-daemon/internal/cli/capability_downloads.go @@ -20,28 +20,7 @@ func withCapabilityDownloads(factory agent.Factory, serverURL string) agent.Fact return factory } return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - opts := maps.Clone(req.AgentOptions) - for _, key := range []string{"skills", "plugins"} { - items, ok := opts[key].([]any) - if !ok { - continue - } - items = slices.Clone(items) - for i, item := range items { - descriptor, ok := item.(map[string]any) - if !ok { - continue - } - raw, _ := descriptor["download_url"].(string) - if resolved := capabilityDownloadURL(raw, base); resolved != raw { - updated := maps.Clone(descriptor) - updated["download_url"] = resolved - items[i] = updated - } - } - opts[key] = items - } - req.AgentOptions = opts + req = capabilityDownloadRequest(req, base) return factory(ctx, req, out) } } @@ -65,3 +44,42 @@ func capabilityDownloadURL(raw string, base *url.URL) string { u.RawPath = "" return u.String() } + +func capabilityDownloadRequest(req proto.PromptRequestPayload, base *url.URL) proto.PromptRequestPayload { + opts := maps.Clone(req.AgentOptions) + for _, key := range []string{"skills", "plugins"} { + items, ok := opts[key].([]any) + if !ok { + continue + } + items = slices.Clone(items) + for i, item := range items { + descriptor, ok := item.(map[string]any) + if !ok { + continue + } + raw, _ := descriptor["download_url"].(string) + if resolved := capabilityDownloadURL(raw, base); resolved != raw { + updated := maps.Clone(descriptor) + updated["download_url"] = resolved + items[i] = updated + } + } + opts[key] = items + } + req.AgentOptions = opts + return req +} + +// Executor setup applies the same immutable capability snapshot as ordinary execution. +func withExecutorCapabilities(factory agent.ExecutorFactory, serverURL string) agent.ExecutorFactory { + base, err := url.Parse(serverURL) + valid := err == nil && base.Host != "" && (base.Scheme == "http" || base.Scheme == "https") + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if valid { + req = capabilityDownloadRequest(req, base) + } + req = skillUploadRequest(req, serverURL) + return factory(ctx, req) + } +} diff --git a/apps/parsar-daemon/internal/cli/capability_downloads_test.go b/apps/parsar-daemon/internal/cli/capability_downloads_test.go index 5dcaf97e8..fb63b1775 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads_test.go +++ b/apps/parsar-daemon/internal/cli/capability_downloads_test.go @@ -92,3 +92,24 @@ func TestCapabilityDownloadsInstallZIPThroughPairedServer(t *testing.T) { t.Fatal("rewrote the original request options") } } + +func TestExecutorPreparationUsesCapabilitySnapshotTransform(t *testing.T) { + originalURL := "http://localhost:8080/internal/blobs/pg:skill" + descriptor := map[string]any{"download_url": originalURL} + source := proto.PromptRequestPayload{AgentOptions: map[string]any{"skills": []any{descriptor}}} + calls := 0 + factory := withExecutorCapabilities(func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + calls++ + actual := req.AgentOptions["skills"].([]any)[0].(map[string]any)["download_url"] + if actual != "http://paired-core:8080/internal/blobs/pg:skill" { + t.Fatalf("executor capability URL = %v", actual) + } + return nil, nil + }, "http://paired-core:8080") + if _, err := factory(t.Context(), source); err != nil { + t.Fatal(err) + } + if calls != 1 || descriptor["download_url"] != originalURL { + t.Fatal("preparation changed the source snapshot or repeated setup") + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index add58c1b1..587867740 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -22,7 +22,7 @@ type claudeSDKDiscovery struct { Config claudesdk.Config } -func discoverClaudeSDK(rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { +func discoverClaudeSDK(parent context.Context, rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { entrypoint := os.Getenv(claudeSDKEntrypointEnv) if entrypoint == "" { return nil @@ -60,14 +60,11 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex return fail(err) } out.Config = claudesdk.Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} - if mode := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_WORKSPACE"); mode != "" { - if mode != "managed" { - return fail(fmt.Errorf("unsupported Claude SDK workspace profile")) - } - binding, err := localworkspace.Load() - if err != nil || binding == nil { - return fail(fmt.Errorf("Claude SDK workspace requires a dedicated local Runtime binding")) - } + binding, err := localworkspace.Load() + if err != nil { + return fail(err) + } + if binding != nil { root, err := paths.Root() if err != nil { return fail(err) @@ -76,7 +73,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if err != nil { return fail(err) } - out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("OAC_RUNTIME_STAGING")) + out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err != nil { return fail(err) } @@ -84,7 +81,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if check == nil { check = claudesdk.CheckRuntime } - info, err := check(context.Background(), out.Config) + info, err := check(parent, out.Config) if err != nil { return fail(err) } @@ -94,7 +91,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } out.Info.Available, out.Info.Version = true, info.SDK @@ -128,6 +125,9 @@ func registerClaudeSDK(registry *agent.Registry, discovery *claudeSDKDiscovery) } } registry.RegisterKind(discovery.Info, factory) + if discovery.Info.Available { + registry.RegisterExecutor("claude_sdk", claudesdk.NewExecutorFactory(discovery.Config)) + } if discovery.Info.Available && discovery.Info.Capabilities.LocalEnvironment { registry.RegisterPreparation("claude_sdk", true, claudesdk.NewPreparationFactory(discovery.Config)) } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go index f9b137da4..468fd2681 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go @@ -57,7 +57,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Setenv(name, value) } stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) if err != nil || discovery.ClaudeSDK == nil || !discovery.ClaudeSDK.Info.Available { t.Fatal("SDK-only discovery failed", err) } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index c691d9292..eeca011c9 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -57,7 +57,7 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { checks.Pi = func(context.Context, string) (string, error) { return "test-pi", nil } } stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "test", checks) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "test", checks) if (err == nil) != (tc.ready || tc.legacy) { t.Fatalf("startup readiness: %v", err) } @@ -78,6 +78,9 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { t.Fatal(err) } info := discovery.ClaudeSDK.Info + if info.Available { + info.Capabilities.Preparation = true + } if info.Available != tc.ready { t.Fatal(info) } @@ -113,7 +116,7 @@ func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { } else { t.Setenv("OAC_RUNTIME_HOME", "relative-home") } - out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { t.Fatal("invalid paths reached runtime probe") return claudesdk.RuntimeInfo{}, nil }) @@ -134,7 +137,7 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} return info, nil }) diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 345ec2b48..c6f216504 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -6,9 +6,7 @@ import ( "fmt" "log/slog" "os" - "os/signal" "strings" - "syscall" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -64,11 +62,19 @@ func runConnect(ctx *runContext, args []string) error { remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") - selfHosted = fs.Bool("self-hosted-install", false, "started by the self-hosted installer: a rejection names its rerun as the fix") ) if err := fs.Parse(args); err != nil { return fmt.Errorf("connect: parse flags: %w", err) } + installation, err := nativeInstallationPath() + if err != nil { + return err + } + if _, err = os.Lstat(installation); err == nil { + return errors.New("connect: this Runtime has a native installation; use oac-daemon start to validate its installed Harnesses") + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("connect: cannot inspect native installation; use oac-daemon start") + } // Hydrate inline pairing inputs from env in BOTH parent and the // re-execed background child. Server-spawned sandboxes pass the // token via OAC_RUNTIME_DAEMON_CONNECT_TOKEN/URL env rather than --url @@ -80,11 +86,13 @@ func runConnect(ctx *runContext, args []string) error { if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } - if *remote != "" || *environment != "" || *credentialFile != "" || *selfHosted { + if *remote != "" || *environment != "" || *credentialFile != "" { if *serverURL != "" || *token != "" || *deviceName != "" || fs.NArg() != 0 { return errors.New("connect: Environment enrollment cannot use pairing options or positional arguments") } - return runEnvironmentConnect(ctx, *profile, *background, *remote, *environment, *credentialFile, *selfHosted) + connectCtx, stop := daemonize.NotifyContext(context.Background()) + defer stop() + return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile) } inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" @@ -115,12 +123,12 @@ func runConnect(ctx *runContext, args []string) error { argv = scrubInlineConnectArgs(os.Args) extraEnv = inlineConnectEnv(*serverURL, *token, *deviceName) } - return spawnBackground(ctx, *profile, argv, extraEnv) + return spawnBackground(context.Background(), ctx, *profile, argv, extraEnv) } // Self-check before pairing/loading credentials so a machine with // no supported agent CLI fails before consuming a one-shot token. - agentCLIs, err := preflightAgentCLIs(ctx, *profile) + agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) if err != nil { return err } @@ -201,7 +209,7 @@ func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.P // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either // mode. -func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []string) error { +func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string, extraEnv []string) error { logPath, err := paths.LogFile(profile) if err != nil { return fmt.Errorf("connect: %w", err) @@ -223,6 +231,9 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s return fmt.Errorf("connect: %w", err) } + if err := ctx.Err(); err != nil { + return err + } pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ LogPath: logPath, PIDPath: pidPath, @@ -232,6 +243,12 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s return fmt.Errorf("connect: spawn background: %w", err) } + if err := ctx.Err(); err != nil { + if stopErr := daemonize.StopPIDFile(pidPath, killTimeout); stopErr != nil { + return fmt.Errorf("connect: interrupted startup cleanup: %w", stopErr) + } + return err + } fmt.Fprintf(rc.stdout, "oac-daemon: backgrounded (pid=%d)\n", pid) fmt.Fprintf(rc.stdout, " logs : %s\n", logPath) fmt.Fprintf(rc.stdout, " pid : %s\n", pidPath) @@ -244,10 +261,10 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s // unblocks the read pump and any in-flight Send so the daemon exits // without orphaning agent subprocesses. func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery) error { - return mainLoopRemote(rc, profile, prof, agentCLIs, "") + return mainLoopRemote(context.Background(), rc, profile, prof, agentCLIs, "") } -func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error { +func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error { // Route through obs/log so daemon log lines pick up the same // trace_id / span_id auto-injection as the server side — when the // daemon adopts an envelope's trace, every log call under that ctx @@ -259,22 +276,9 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs Out: rc.stderr, }) - rootCtx, cancel := context.WithCancel(context.Background()) + rootCtx, cancel := daemonize.NotifyContext(parent) defer cancel() - // Honour SIGINT / SIGTERM as graceful shutdown. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - select { - case sig := <-sigCh: - obslog.Bg().Info("received signal, shutting down", "signal", sig.String()) - cancel() - case <-rootCtx.Done(): - } - signal.Stop(sigCh) - }() - bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) var boot *transport.BootstrapResponse var err error @@ -309,7 +313,7 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs DeviceID: boot.DeviceID, Credential: prof.RunnerCredential, // DaemonVersion is the WIRE-PROTOCOL version, not the build - // tag. proto.VersionCompatible is a strict major.minor + // tag. proto.VersionCompatible requires an exact version // match against proto.Version. Build-tag reporting goes // in heartbeat's DaemonVersion field. DaemonVersion: proto.Version, @@ -387,8 +391,8 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs // pumpConn runs the per-connection workload: a dispatch.Router fed by // conn.Recv(), heartbeats every boot.HeartbeatInterval(), and a -// graceful router.Shutdown on exit so any in-flight subprocesses get -// SIGTERM. +// confirmed router.Shutdown before returning ownership to the reconnect loop. +// Failed cleanup keeps this exact Router alive, including after a shutdown signal. func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { local, err := localworkspace.Load() if err != nil { @@ -406,9 +410,8 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R return fmt.Errorf("router init: %w", err) } defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = router.Shutdown(shutdownCtx) - cancel() + _ = conn.Close() + shutdownRouterUntilConfirmed(router.Shutdown, time.Second) }() conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { @@ -421,7 +424,6 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R Timestamp: time.Now().Unix(), ActiveRequests: router.ActiveRuns(), DaemonVersion: Version, - ClaudeAvailable: agentCLIs.ClaudeCode.Available, // legacy server compatibility SupportedAgentKinds: kinds, } }, obslog.Bg().With("component", "heartbeat")) diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup.go b/apps/parsar-daemon/internal/cli/connect_cleanup.go new file mode 100644 index 000000000..7909d7347 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_cleanup.go @@ -0,0 +1,24 @@ +package cli + +import ( + "context" + "time" + + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// A wait deadline does not revoke native ownership. Keep retrying the same +// Router until it confirms cleanup; reconnect and process exit both wait here. +// Router.Shutdown serializes attempts and retains resources after a failure. +func shutdownRouterUntilConfirmed(shutdown func(context.Context) error, retryDelay time.Duration) { + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := shutdown(ctx) + cancel() + if err == nil { + return + } + obslog.Bg().Warn("router cleanup unconfirmed; reconnect remains blocked", "err", err) + time.Sleep(retryDelay) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup_test.go b/apps/parsar-daemon/internal/cli/connect_cleanup_test.go new file mode 100644 index 000000000..e5da165fd --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_cleanup_test.go @@ -0,0 +1,196 @@ +package cli + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "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/gorilla/websocket" + "sync/atomic" + "testing" + "time" +) + +func TestReconnectWaitsForConfirmedRouterCleanup(t *testing.T) { + for _, failure := range []error{errors.New("native close failed"), context.DeadlineExceeded} { + t.Run(failure.Error(), func(t *testing.T) { + var calls atomic.Int32 + retry := make(chan struct{}) + confirm := make(chan struct{}) + returned := make(chan struct{}) + go func() { + shutdownRouterUntilConfirmed(func(ctx context.Context) error { + if ctx.Err() != nil { + t.Error("cleanup inherited a cancelled connection context") + } + if _, ok := ctx.Deadline(); !ok { + t.Error("cleanup wait has no deadline") + } + if calls.Add(1) == 1 { + return failure + } + close(retry) + <-confirm + return nil + }, time.Millisecond) + close(returned) + }() + select { + case <-retry: + case <-time.After(time.Second): + t.Fatal("same cleanup was not retried") + } + select { + case <-returned: + t.Fatal("reconnect released unconfirmed ownership") + default: + } + close(confirm) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("confirmed cleanup did not release reconnect") + } + if calls.Load() != 2 { + t.Fatalf("cleanup attempts=%d", calls.Load()) + } + }) + } +} + +type cleanupExecutor struct { + closes atomic.Int32 + retry chan struct{} + confirm chan struct{} +} + +func (e *cleanupExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { + return nil, errors.New("unexpected Turn") +} +func (e *cleanupExecutor) Close(context.Context) error { + if e.closes.Add(1) == 1 { + return errors.New("native cleanup temporarily unavailable") + } + close(e.retry) + <-e.confirm + return nil +} + +func TestDisconnectedPumpRetainsExactExecutorUntilCleanup(t *testing.T) { + for _, suspend := range []bool{false, true} { + name := "ordinary" + if suspend { + name = "suspension-enabled" + } + t.Run(name, func(t *testing.T) { testDisconnectedPumpCleanup(t, suspend) }) + } +} + +func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { + t.Setenv("OAC_RUNTIME_WORKSPACE", "") + peers := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err == nil { + peers <- peer + } + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + dial := func(ctx context.Context) (*transport.Conn, error) { + return transport.Dial(ctx, transport.DialOptions{ + WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", + Credential: "credential", DaemonVersion: proto.Version, + }) + } + owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("unexpected legacy factory") + }) + var factories atomic.Int32 + registry.RegisterExecutor("cleanup", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + factories.Add(1) + return owner, nil + }) + finished := make(chan error, 1) + go func() { + boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} + if suspend { + control := &suspendControl{signal: make(chan os.Signal, 1)} + finished <- runSuspendLoop(ctx, dial, registry, boot, agentCLIDiscovery{}, control) + return + } + conn, err := dial(ctx) + if err != nil { + finished <- err + return + } + defer conn.Close() + finished <- pumpConn(ctx, conn, registry, boot, agentCLIDiscovery{}) + }() + var peer *websocket.Conn + select { + case peer = <-peers: + case <-time.After(3 * time.Second): + t.Fatal("initial connection missing") + } + defer peer.Close() + env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", + Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true}}) + if err != nil { + t.Fatal(err) + } + if err := peer.WriteJSON(env); err != nil { + t.Fatal(err) + } + _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var result proto.Envelope + if err := peer.ReadJSON(&result); err != nil { + t.Fatal(err) + } + if result.Type != proto.TypePreparationStatus { + continue + } + var status proto.PreparationStatusPayload + if err := result.DecodePayload(&status); err != nil { + t.Fatal(err) + } + if status.State == "ready" { + break + } + if status.State == "failed" { + t.Fatalf("preparation failed: %+v", status) + } + } + _ = peer.Close() + select { + case <-owner.retry: + case <-time.After(3 * time.Second): + t.Fatal("original executor cleanup was not retried") + } + select { + case err := <-finished: + t.Fatalf("pump discarded native ownership: %v", err) + default: + } + cancel() + close(owner.confirm) + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("pump did not return after confirmed cleanup") + } + if factories.Load() != 1 || owner.closes.Load() != 2 { + t.Fatalf("factory=%d close=%d", factories.Load(), owner.closes.Load()) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index e02c1c551..65440c9c1 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -11,13 +11,12 @@ import ( "net/http" "net/url" "os" - "os/signal" "strings" - "syscall" "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/google/uuid" ) @@ -113,7 +112,7 @@ func enrollEnvironment(ctx context.Context, client *http.Client, base, environme return out, fmt.Errorf("connect: Environment enrollment rejected (HTTP %d)", resp.StatusCode) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024+1)) - if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory != "/workspace" { + if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory == "/" || agentcapabilities.ValidateLocalDirectories([]string{out.WorkspaceDirectory}) != nil { return environmentEnrollment{}, errors.New("connect: invalid Environment enrollment response") } return out, nil @@ -130,7 +129,7 @@ func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) return boot, nil } -func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string, selfHosted bool) error { +func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { base, err := environmentBase(remote) if err != nil { return err @@ -149,30 +148,33 @@ func runEnvironmentConnect(rc *runContext, profile string, background bool, remo // the connection parks instead. parks := !background || daemonize.IsBackgroundChild() rejected := func(err error) error { - if message := environmentRejection(err, keyID, environment, selfHosted); parks && message != "" { - return parkEnvironment(rc.stderr, message) + if message := environmentRejection(err, keyID, environment); parks && message != "" { + return parkEnvironment(parent, rc.stderr, message) } return err } - ctx, cancel := context.WithTimeout(context.Background(), bootstrapTimeout) + ctx, cancel := context.WithTimeout(parent, bootstrapTimeout) defer cancel() bound, err := enrollEnvironment(ctx, environmentClient(), base, environment, credential) if err != nil { return rejected(err) } + if err = parent.Err(); err != nil { + return err + } if err = bindEnvironmentRuntime(remote, bound, credentialFile); err != nil { return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(rc, profile, os.Args, nil) + return spawnBackground(parent, rc, profile, os.Args, nil) } // Discovery consumes the immutable Runtime binding; it must follow enrollment. - discovery, err := preflightAgentCLIs(rc, profile) + discovery, err := preflightAgentCLIs(parent, rc, profile) if err != nil { return err } prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} - return rejected(mainLoopRemote(rc, profile, prof, discovery, remote)) + return rejected(mainLoopRemote(parent, rc, profile, prof, discovery, remote)) } var ( @@ -183,13 +185,9 @@ var ( // environmentRejection names the fix for a permanent Environment rejection: // enrollment 401 or 409, or a permanent WebSocket rejection or close. It returns // "" for anything else (transport failures, 5xx, 404), which keeps the ordinary -// failure exit so the Runtime's restart policy retries it. Only a Runtime the -// self-hosted installer started names that installer's rerun and container. -func environmentRejection(err error, keyID, environment string, selfHosted bool) string { +// failure exit so the Runtime's restart policy retries it. +func environmentRejection(err error, keyID, environment string) string { reconnect, remove := "install it for this Runtime and restart it", "stop this Runtime" - if selfHosted { - reconnect, remove = "rerun the self-hosted install command on this host and paste it", "stop this container" - } switch { case errors.Is(err, errEnvironmentBindingConflict): return fmt.Sprintf("executor credential %s cannot connect: Environment %s is bound to a different executor credential. This Runtime will not retry. Rotate the credential first used for this Environment instead of issuing a new one, then %s. To remove this Runtime instead, %s.", keyID, environment, reconnect, remove) @@ -205,8 +203,8 @@ func environmentRejection(err error, keyID, environment string, selfHosted bool) // until SIGINT or SIGTERM and exits successfully. Docker's unless-stopped policy // restarts every exit, so an exit would loop; a parked Runtime still restarts // after a reboot, makes one enrollment request and parks again. -func parkEnvironment(stderr io.Writer, message string) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +func parkEnvironment(parent context.Context, stderr io.Writer, message string) error { + ctx, stop := daemonize.NotifyContext(parent) defer stop() fmt.Fprintln(stderr, "oac-daemon: "+message) <-ctx.Done() diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go index df3d31833..df771a66f 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -1,23 +1,23 @@ package cli import ( - "bytes" "encoding/json" "errors" - "io" "os" "path/filepath" - "syscall" "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" ) // This receipt contains identity only; executor credentials remain in their file. type environmentBinding struct { - RemoteURL string `json:"remote_url"` - Enrollment environmentEnrollment `json:"enrollment"` - LocalWorkspace string `json:"local_workspace"` + RemoteURL string `json:"remote_url"` + Enrollment environmentEnrollment `json:"enrollment"` + LocalWorkspace string `json:"local_workspace"` + CapabilityDirectory string `json:"capability_directory"` } // Check persisted ownership before transmitting the executor credential. @@ -38,27 +38,7 @@ func checkEnvironmentTarget(remote, environment string) error { } func readEnvironmentPrivateFile(path string) ([]byte, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("absolute private file required") - } - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) - if err != nil { - return nil, err - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { - return nil, errors.New("private owned regular file required") - } - raw, err := io.ReadAll(io.LimitReader(f, 16*1024+1)) - if err != nil || len(raw) > 16*1024 { - return nil, errors.New("private file exceeds limit") - } - return raw, nil + return runtimefs.ReadPrivatePath(path, 16*1024) } func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { @@ -66,12 +46,19 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err != nil || !filepath.IsAbs(root) { return errors.New("connect: absolute Runtime state directory required") } - if err = os.MkdirAll(root, 0700); err != nil { + if err = runtimefs.EnsurePrivateDir(root); err != nil { return errors.New("connect: Runtime state directory unavailable") } workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") - if workspace != "/environment/workspace" { - return errors.New("connect: packaged /workspace Runtime required") + if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { + return errors.New("connect: clean absolute Runtime workspace required") + } + info, statErr := os.Stat(workspace) + if statErr != nil || !info.IsDir() { + return errors.New("connect: existing Runtime workspace required") + } + if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { + return errors.New("connect: enrollment does not match the Runtime workspace") } for key, value := range map[string]string{ "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, @@ -85,31 +72,14 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if domains := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); domains != "" && domains != "[]" { return errors.New("connect: conflicting Runtime network domains") } - resolvedRoot, err := filepath.Abs(root) - if err != nil { - return errors.New("connect: Runtime state directory unavailable") + capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if capabilityDirectory == "" { + capabilityDirectory = localworkspace.CapabilityDirectory } - for _, path := range []string{resolvedRoot, credentialFile} { - resolved, e := filepath.EvalSymlinks(path) - if e != nil { - return errors.New("connect: private Runtime path unavailable") - } - for _, public := range []string{"/workspace", workspace} { - if relative, e := filepath.Rel(public, resolved); e == nil && (relative == "." || filepath.IsLocal(relative)) { - return errors.New("connect: private Runtime state cannot be inside the workspace") - } - } - } - private, err := filepath.EvalSymlinks(filepath.Join(root, "daemon")) - credentialPath, credentialErr := filepath.EvalSymlinks(credentialFile) - if err != nil || credentialErr != nil { - return errors.New("connect: protected daemon credential directory required") + if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { + return errors.New("connect: local Runtime layout unavailable") } - relative, err := filepath.Rel(private, credentialPath) - if err != nil || !filepath.IsLocal(relative) || relative == "." { - return errors.New("connect: executor credential must be inside the protected daemon directory") - } - want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace} + want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory} if err = saveEnvironmentBinding(root, want); err != nil { return err } @@ -120,25 +90,37 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti } local, err := localworkspace.Load() if err != nil || local == nil { - return errors.New("connect: packaged Runtime binding or helpers unavailable") + return errors.New("connect: Runtime binding unavailable") } return nil } +// Neither selecting private state nor selecting its parent grants workspace access. +func environmentPathsOverlap(first, second string) bool { + for _, pair := range [][2]string{{first, second}, {second, first}} { + relative, err := filepath.Rel(pair[0], pair[1]) + if err == nil && (relative == "." || filepath.IsLocal(relative)) { + return true + } + } + return false +} + func saveEnvironmentBinding(root string, want environmentBinding) error { - // All three native sandboxes protect this existing daemon state directory. + // Store the binding with the other daemon state. dir := filepath.Join(root, "daemon") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return errors.New("connect: Runtime state directory unavailable") } for _, path := range []string{root, dir} { - info, e := os.Lstat(path) - if e != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return errors.New("connect: Runtime state directory must be private") + held, err := os.OpenRoot(path) + if err != nil { + return errors.New("connect: Runtime state directory unavailable") } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || st.Uid != uint32(os.Getuid()) { - return errors.New("connect: Runtime state directory must be owned") + err = runtimefs.PrivateDirectory(held) + held.Close() + if err != nil { + return errors.New("connect: Runtime state directory must be private and owned") } } path := filepath.Join(dir, "environment.json") @@ -169,14 +151,19 @@ func saveEnvironmentBinding(root string, want environmentBinding) error { } } data, _ := json.Marshal(want) - f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + held, e := os.OpenRoot(dir) + if e != nil { + return errors.New("connect: Runtime binding directory unavailable") + } + defer held.Close() + f, e := runtimefs.OpenPrivate(held, "environment.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL) if errors.Is(e, os.ErrExist) { return saveEnvironmentBinding(root, want) } if e != nil { return errors.New("connect: could not establish Runtime binding") } - _, e = io.Copy(f, bytes.NewReader(data)) + _, e = f.Write(data) if e == nil { e = f.Sync() } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go new file mode 100644 index 000000000..0f2b3ac68 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go @@ -0,0 +1,140 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string, bound environmentEnrollment) { + t.Helper() + base := t.TempDir() + root, workspace = filepath.Join(base, "private"), filepath.Join(base, "workspace") + for _, dir := range []string{filepath.Join(root, "daemon"), workspace} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + credential = filepath.Join(root, "daemon", "executor.json") + if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) + for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS"} { + t.Setenv(name, "") + } + return root, workspace, credential, environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"} +} + +func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { + for _, physical := range []bool{false, true} { + t.Run(map[bool]string{false: "logical", true: "physical"}[physical], func(t *testing.T) { + root, workspace, credential, bound := environmentRuntimeFixture(t) + if physical { + bound.WorkspaceDirectory = workspace + } + if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err != nil { + t.Fatal(err) + } + raw, err := readEnvironmentPrivateFile(filepath.Join(root, "daemon", "environment.json")) + if err != nil { + t.Fatal(err) + } + var receipt environmentBinding + if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != bound { + t.Fatal("workspace identity changed", receipt) + } + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err != nil { + t.Fatal("same binding refused", err) + } + replacement := filepath.Join(filepath.Dir(workspace), "replacement") + if err := os.Mkdir(replacement, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_WORKSPACE", replacement) + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err == nil { + t.Fatal("existing workspace identity replaced") + } + after, _ := os.ReadFile(filepath.Join(root, "daemon", "environment.json")) + if string(after) != string(raw) { + t.Fatal("failed rebind changed identity") + } + contents, _ := os.ReadFile(credential) + if string(contents) != "private-test-credential" { + t.Fatal("credential changed") + } + }) + } +} + +func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { + for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { + t.Run(kind, func(t *testing.T) { + root, workspace, credential, bound := environmentRuntimeFixture(t) + selected := workspace + switch kind { + case "relative": + selected = "workspace" + case "unclean": + selected = workspace + "/../workspace" + case "missing": + selected = filepath.Join(filepath.Dir(workspace), "missing") + case "file": + selected = filepath.Join(filepath.Dir(workspace), "regular") + if err := os.WriteFile(selected, nil, 0600); err != nil { + t.Fatal(err) + } + case "symlink": + selected = filepath.Join(filepath.Dir(workspace), "linked") + if err := os.Symlink(workspace, selected); err != nil { + t.Fatal(err) + } + case "parent symlink": + child := filepath.Join(workspace, "child") + if err := os.Mkdir(child, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(filepath.Dir(workspace), "linked-parent") + if err := os.Symlink(workspace, alias); err != nil { + t.Fatal(err) + } + selected = filepath.Join(alias, "child") + case "workspace contains private": + selected = filepath.Dir(root) + case "workspace inside private": + selected = filepath.Join(root, "nested-workspace") + if err := os.Mkdir(selected, 0700); err != nil { + t.Fatal(err) + } + case "foreign enrollment": + bound.WorkspaceDirectory = filepath.Join(filepath.Dir(workspace), "different") + case "credential outside private": + credential = filepath.Join(filepath.Dir(root), "credential") + if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { + t.Fatal(err) + } + case "identity conflict": + t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) + } + t.Setenv("OAC_RUNTIME_WORKSPACE", selected) + allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" || kind == "credential outside private" + err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential) + if allowed { + if err != nil { + t.Fatal("operator layout rejected", err) + } + } else { + if err == nil { + t.Fatal("invalid binding accepted") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { + t.Fatal("invalid binding published identity", err) + } + } + + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go index d1cd31cf4..9d15f20ef 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go @@ -76,7 +76,7 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { t.Fatal(err) } args, _ := json.Marshal([]string{"connect", "--remote", "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws", - "--environment-id", environment, "--credential-file", credential, "--self-hosted-install"}) + "--environment-id", environment, "--credential-file", credential}) cmd := exec.Command(os.Args[0], "-test.run=^TestEnvironmentRejectionParksUntilTerminated$") cmd.Env = append(os.Environ(), "OAC_TEST_ENVIRONMENT_CONNECT="+string(args), "OAC_RUNTIME_HOME="+home) var stderr lockedBuffer @@ -137,19 +137,17 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { func TestEnvironmentRejectionClassifiesConnectionErrors(t *testing.T) { for _, tc := range []struct { - err error - selfHosted bool - want string + err error + want string }{ - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), true, "rerun the self-hosted install command"}, - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), false, "install it for this Runtime and restart it"}, - {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), true, "daemon version"}, - {errors.New("connect: bootstrap: Environment bootstrap failed"), true, ""}, - {nil, true, ""}, + {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), "install it for this Runtime and restart it"}, + {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), "daemon version"}, + {errors.New("connect: bootstrap: Environment bootstrap failed"), ""}, + {nil, ""}, } { - got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString(), tc.selfHosted) - if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || !tc.selfHosted && strings.Contains(got, "container") { - t.Errorf("environmentRejection(%v, %v) = %q", tc.err, tc.selfHosted, got) + got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString()) + if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || strings.Contains(got, "container") { + t.Errorf("environmentRejection(%v) = %q", tc.err, got) } } } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/parsar-daemon/internal/cli/connect_environment_test.go index dd03b66dd..2415ff942 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_test.go @@ -138,14 +138,14 @@ func TestExecutorCredentialFile(t *testing.T) { if err := os.Symlink(path, link); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(link, environment); err == nil { - t.Fatal("symlink accepted") + if _, _, err := executorCredential(link, environment); err != nil { + t.Fatal("operator symlink rejected", err) } if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(path, environment); err == nil { - t.Fatal("public credential accepted") + if _, _, err := executorCredential(path, environment); err != nil { + t.Fatal("operator permissions rejected", err) } } @@ -154,7 +154,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -186,12 +186,12 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { } } -func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing.T) { +func TestEnvironmentBindingAllowsOperatorFilePermissions(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := os.WriteFile(filepath.Join(root, "installed-bundle"), []byte("bundle"), 0600); err != nil { t.Fatal(err) } @@ -202,8 +202,8 @@ func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if err := saveEnvironmentBinding(root, want); err == nil { - t.Fatal("public receipt accepted") + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal("operator permissions rejected", err) } } @@ -213,7 +213,7 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -231,7 +231,7 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { environment := uuid.NewString() good := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} - for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "/different" }} { + for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "relative" }} { bad := good mutate(&bad) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(bad) })) @@ -253,3 +253,14 @@ func TestEnvironmentConnectRejectsPairing(t *testing.T) { t.Fatal("pairing accepted or leaked") } } + +func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { + environment := uuid.NewString() + want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(want) })) + defer server.Close() + actual, err := enrollEnvironment(t.Context(), environmentClient(), server.URL, environment, "secret") + if err != nil || actual != want { + t.Fatal("canonical workspace selection refused", actual, err) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_suspend.go b/apps/parsar-daemon/internal/cli/connect_suspend.go index 38a391252..0253f0a5f 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend.go @@ -55,10 +55,8 @@ func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry) (*suspen return &suspendedRouter{router: router, bridge: bridge, sender: sender, registry: wrapped, local: local}, nil } -func (s *suspendedRouter) shutdown() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return s.router.Shutdown(ctx) +func (s *suspendedRouter) shutdown() { + shutdownRouterUntilConfirmed(s.router.Shutdown, time.Second) } // runSuspendLoop uses the ordinary connection authentication and dispatch chain. @@ -78,10 +76,7 @@ func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent. return err } err = serveSuspendLifecycle(ctx, conn, dial, state, boot, discovery, control) - closeErr := state.shutdown() - if closeErr != nil { - return errors.Join(err, closeErr) - } + state.shutdown() if ctx.Err() != nil { return nil } @@ -166,7 +161,7 @@ func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, caps := &kinds[i].Capabilities caps.WorkspaceOutputExport = s.local.CanExport() && caps.LocalEnvironment && caps.WorkspaceReadPreparation } - return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, ClaudeAvailable: discovery.ClaudeCode.Available, SupportedAgentKinds: kinds} + return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: kinds} }, obslog.Bg()) } diff --git a/apps/parsar-daemon/internal/cli/connect_suspend_test.go b/apps/parsar-daemon/internal/cli/connect_suspend_test.go index 072c1fc78..80e1961c5 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend_test.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend_test.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index af4464f15..c3077655e 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -72,7 +72,7 @@ func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, ClaudeCode: func(context.Context, string) (string, error) { return "", claudecode.ErrCLINotFound @@ -117,7 +117,7 @@ func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, ClaudeCode: func(context.Context, string) (string, error) { return "", claudecode.ErrCLINotFound @@ -147,7 +147,7 @@ func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "0.4.12", nil }, ClaudeCode: func(context.Context, string) (string, error) { return "claude 2.0.0", nil diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index da0fc60ac..a8bf3e807 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -8,12 +8,12 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func discoverMCode(rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { +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{Streaming: true, Permissions: true, Resume: true}} - ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + ctx, cancel := context.WithTimeout(parent, cliVersionTimeout) defer cancel() version, err := check(ctx, "") if err != nil { diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/parsar-daemon/internal/cli/mcode_execution_test.go index 1203f20c6..1f03f381f 100644 --- a/apps/parsar-daemon/internal/cli/mcode_execution_test.go +++ b/apps/parsar-daemon/internal/cli/mcode_execution_test.go @@ -14,7 +14,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) rc := &runContext{stdout: io.Discard, stderr: io.Discard} - info := discoverMCode(rc, func(context.Context, string) (string, error) { return tc.version, nil }) + info := discoverMCode(t.Context(), rc, func(context.Context, string) (string, error) { return tc.version, nil }) if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified || info.Capabilities.SubagentObservations != tc.qualified { t.Fatalf("capabilities=%+v", info.Capabilities) } diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index 9e7d45f8d..e100edb4e 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -13,22 +13,21 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" ) -func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { - mode := os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE") - if mode == "" { - return - } +func discoverMCodeWorkspace(parent context.Context, rc *runContext, discovery *agentCLIDiscovery) { fail := func(err error) { discovery.MCode.Available = false fmt.Fprintf(rc.stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } - if mode != "managed" || !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { - fail(fmt.Errorf("managed execution requires the qualified native version and opt-in")) + binding, err := localworkspace.Load() + if err != nil { + fail(err) + return + } + if binding == nil { return } - binding, err := localworkspace.Load() - if err != nil || binding == nil { - fail(fmt.Errorf("dedicated local Runtime binding required")) + if !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { + fail(fmt.Errorf("local execution requires the qualified native version")) return } root, err := paths.Root() @@ -60,9 +59,9 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { fail(err) return } - c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("OAC_RUNTIME_STAGING")) + c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err == nil { - err = mcode.CheckWorkspace(context.Background(), c) + err = mcode.CheckWorkspace(parent, c) } if err != nil { fail(err) @@ -71,6 +70,6 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { discovery.MCodeWorkspace = &c caps := &discovery.MCode.Capabilities caps.EnvironmentNone = false - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation = true } diff --git a/apps/parsar-daemon/internal/cli/mcp_test.go b/apps/parsar-daemon/internal/cli/mcp_test.go index 1ff92f69e..aee418290 100644 --- a/apps/parsar-daemon/internal/cli/mcp_test.go +++ b/apps/parsar-daemon/internal/cli/mcp_test.go @@ -12,7 +12,7 @@ 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(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) if err != nil { t.Fatal(err) } @@ -32,7 +32,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { checks := unavailableCLIChecks() checks.Codex = func(context.Context, string) (string, error) { return version, nil } - got, err := discoverAgentCLIs(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + got, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/parsar-daemon/internal/cli/native_bundle.go new file mode 100644 index 000000000..51f6adb34 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -0,0 +1,230 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" +) + +// This is release content, never an input file for installation options. +type nativeBundle struct { + Schema int `json:"schema"` + DaemonVersion string `json:"daemon_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Components map[string]nativeComponent `json:"components"` +} +type nativeComponent struct { + Version string `json:"version"` + Files map[string]nativeFile `json:"files"` +} +type nativeFile struct { + SHA256 string `json:"sha256"` + Executable bool `json:"executable"` +} + +var nativePins = func() map[string]string { + out := map[string]string{"node": "22.22.0"} + for name, spec := range nativeHarnesses { + out[name] = spec.Version + } + return out +}() + +func readNativeJSON(file string, value any) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 16<<20+1)) + if err != nil || len(raw) > 16<<20 { + return errors.New("installation metadata unavailable") + } + return decodeEnvironmentJSON(raw, value) +} + +func readNativeBundle(directory string, selected []string) (nativeBundle, error) { + var b nativeBundle + if readNativeJSON(filepath.Join(directory, "bundle.json"), &b) != nil { + return b, errors.New("install: use the native installer distribution containing bundle.json") + } + if b.Schema != 1 || b.DaemonVersion != Version || b.OS != runtime.GOOS || b.Arch != runtime.GOARCH { + return b, errors.New("install: distribution version or platform mismatch; use a matching current release") + } + for _, name := range append([]string{"node"}, selected...) { + c, ok := b.Components[name] + if !ok || c.Version != nativePins[name] || len(c.Files) == 0 { + return b, fmt.Errorf("install: pinned %s component missing from this distribution", name) + } + for file, info := range c.Files { + if !validBundlePath(file) || len(info.SHA256) != 64 { + return b, errors.New("install: invalid component file manifest") + } + if _, err := hex.DecodeString(info.SHA256); err != nil { + return b, errors.New("install: invalid component digest") + } + } + } + return b, nil +} + +func validBundlePath(name string) bool { + return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" +} + +func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } + +func componentReceipt(root string) (nativeComponent, error) { + var c nativeComponent + err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) + return c, err +} + +func checkComponentFiles(directory string, c nativeComponent) error { + root, err := os.OpenRoot(directory) + if err != nil { + return err + } + defer root.Close() + for name, expected := range c.Files { + if !validBundlePath(name) { + return errors.New("invalid installed path") + } + f, err := root.Open(filepath.FromSlash(name)) + if err != nil { + return err + } + info, e := f.Stat() + h := sha256.New() + _, copyErr := io.Copy(h, f) + f.Close() + if e != nil || !info.Mode().IsRegular() || copyErr != nil || hex.EncodeToString(h.Sum(nil)) != expected.SHA256 || (runtime.GOOS != "windows" && expected.Executable && info.Mode().Perm()&0100 == 0) { + return errors.New("installed files do not match the verified release") + } + } + return nil +} + +// A complete component is published once. An interruption cannot turn a partial +// copy into an installation; a subsequent run can reuse an already-published one. +func installNativeComponent(ctx context.Context, source, root, name string, expected nativeComponent) error { + dest := nativeComponentRoot(root, name) + if _, err := os.Lstat(dest); err == nil { + got, e := componentReceipt(dest) + if e != nil || !reflect.DeepEqual(got, expected) { + return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) + } + if checkComponentFiles(dest, got) != nil { + return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + parent := filepath.Dir(dest) + if err := os.MkdirAll(parent, 0700); err != nil { + return err + } + tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + src, err := os.OpenRoot(filepath.Join(source, "components", name)) + if err != nil { + return err + } + defer src.Close() + for name, expectedFile := range expected.Files { + if err := ctx.Err(); err != nil { + return err + } + if !validBundlePath(name) { + return errors.New("install: invalid component path") + } + in, err := src.Open(filepath.FromSlash(name)) + if err != nil { + return errors.New("install: component file unavailable") + } + info, err := in.Stat() + if err != nil || !info.Mode().IsRegular() { + in.Close() + return errors.New("install: component must contain regular files") + } + destination := filepath.Join(tmp, filepath.FromSlash(name)) + if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + in.Close() + return err + } + mode := os.FileMode(0600) + if expectedFile.Executable { + mode = 0700 + } + out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + in.Close() + return err + } + h := sha256.New() + _, err = io.Copy(io.MultiWriter(out, h), nativeCopyReader{ctx: ctx, Reader: in}) + in.Close() + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil || closeErr != nil || hex.EncodeToString(h.Sum(nil)) != expectedFile.SHA256 { + return errors.New("install: component checksum failed") + } + } + raw, _ := json.Marshal(expected) + if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return os.Rename(tmp, dest) +} + +func selectedNativeHarnesses(value string) ([]string, error) { + var out []string + for _, name := range strings.Split(value, ",") { + name = strings.TrimSpace(name) + spec, ok := nativeHarnesses[name] + if !ok { + return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") + } + if !spec.Supported() { + return nil, fmt.Errorf("install: %s adapter is unsupported on %s; select a supported Harness", name, runtime.GOOS) + } + if !slices.Contains(out, name) { + out = append(out, name) + } + } + slices.Sort(out) + return out, nil +} + +type nativeCopyReader struct { + io.Reader + ctx context.Context +} + +func (r nativeCopyReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.Reader.Read(p) +} diff --git a/apps/parsar-daemon/internal/cli/native_discovery_test.go b/apps/parsar-daemon/internal/cli/native_discovery_test.go new file mode 100644 index 000000000..67a74543d --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_discovery_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "context" + "io" + "os" + "path/filepath" + "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" +) + +func TestNativeInstallationDiscoversAndRegistersOnlySelectedHarness(t *testing.T) { + for _, name := range []string{"codex", "claude", "minimax"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "host-claude", "main.js")) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, binary) + called := make(map[string]bool) + check := func(kind, version string) func(context.Context, string) (string, error) { + return func(context.Context, string) (string, error) { called[kind] = true; return version, nil } + } + checks := agentCLIChecks{ + ClaudeCode: check("claude_code", "2.1.269"), + OpenCode: check("opencode", "1.0.0"), + Codex: check("codex", "codex-cli 0.153.4"), + Pi: check("pi", "0.1.0"), + MCode: check("mcode", "0.4.12"), + ClaudeSDK: func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + called["claude_sdk"] = true + return claudesdk.RuntimeInfo{SDK: "0.3.269"}, nil + }, + } + kinds := nativeInstallationKinds([]string{name}) + rc := &runContext{stdout: io.Discard, stderr: io.Discard, installedKinds: kinds} + path := os.Getenv("PATH") + discovery, err := discoverAgentCLIs(t.Context(), rc, "default", checks) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(called, kinds) { + t.Fatalf("probed unselected host Harness: got %v, want %v", called, kinds) + } + registry := agent.NewRegistry() + registerAgentKinds(registry, discovery, "") + expected := []string{nativeHarnesses[name].AgentKind} + if !reflect.DeepEqual(registry.Kinds(), expected) { + t.Fatalf("registered unselected host Harness: %v", registry.Kinds()) + } + if os.Getenv("PATH") != path { + t.Fatal("installation discovery removed the tool PATH") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/parsar-daemon/internal/cli/native_harness.go new file mode 100644 index 000000000..41d6cb766 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -0,0 +1,103 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "os" + "path/filepath" + "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" +) + +func nativeExe(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} +func nativeNode(root string) string { + base := nativeComponentRoot(root, "node") + if runtime.GOOS != "windows" { + base = filepath.Join(base, "bin") + } + return filepath.Join(base, nativeExe("node")) +} + +func nativeHarnessEnvironment(root string, selected []string) map[string]string { + node := nativeNode(root) + values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} + for _, name := range selected { + dir := nativeComponentRoot(root, name) + values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + for key, value := range nativeHarnesses[name].Environment(dir, node) { + values[key] = value + } + + } + return values +} + +func withNativeEnv(values map[string]string) []string { + env := make([]string, 0, len(os.Environ())+len(values)) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + // Windows environment names are case-insensitive. + replaced := false + for k := range values { + if strings.EqualFold(k, key) { + replaced = true + break + } + } + if !replaced { + env = append(env, entry) + } + } + for key, value := range values { + env = append(env, key+"="+value) + } + return env +} + +// Installation registration is local to Runtime; Core does not see native paths. +var nativeHarnesses = map[string]agent.Installation{ + "codex": codex.Installation(), "claude": claudesdk.Installation(), "minimax": mcode.Installation(), +} +var probeNativeInstallation = checkNativeInstallation + +func checkNativeInstallation(ctx context.Context, root string, selected []string) error { + env := withNativeEnv(nativeHarnessEnvironment(root, selected)) + node := nativeNode(root) + version, err := installroot.Probe(ctx, node, []string{"--version"}, env, root) + if err != nil || version != "v"+nativePins["node"] { + return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") + } + for _, name := range selected { + spec, ok := nativeHarnesses[name] + if !ok || !spec.Supported() { + return fmt.Errorf("install: %s is unsupported on this platform", name) + } + if err = spec.Check(ctx, nativeComponentRoot(root, name), node, env); err != nil { + return fmt.Errorf("install: %s; no installed files were replaced", err) + } + } + return nil +} + +// Installed discovery is confined to verified adapters; ordinary tool PATH +// remains available to the selected Harness and its tools. +func nativeInstallationKinds(selected []string) map[string]bool { + kinds := make(map[string]bool, len(selected)) + for _, name := range selected { + kinds[nativeHarnesses[name].AgentKind] = true + } + return kinds +} diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go new file mode 100644 index 000000000..17ee20d0c --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -0,0 +1,328 @@ +package cli + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "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" +) + +// Installation is local state, not an options-file input or an OS service. +type nativeInstallation struct { + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Workspace string `json:"workspace_directory"` + Credential string `json:"credential_file"` + CapabilityDirectory string `json:"capability_directory"` + ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` + Harnesses []string `json:"harnesses"` +} + +func nativeInstallationPath() (string, error) { + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, "daemon", "installation.json"), nil +} + +func lockNativeInstallation(root string) (*os.Root, func(), error) { + dir := filepath.Join(root, "daemon") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + return nil, nil, err + } + held, err := os.OpenRoot(dir) + if err != nil { + return nil, nil, err + } + unlock, err := runtimefs.LockDirectory(held) + if err != nil { + held.Close() + return nil, nil, errors.New("installation is busy; wait for the other operation to finish") + } + return held, func() { unlock(); held.Close() }, nil +} + +func validateNativeInstallation(c nativeInstallation) error { + if c.Version != Version { + return errors.New("installation version is unsupported; preserve this installation and reinstall in a separate directory") + } + if _, err := environmentBase(c.Remote); err != nil { + return err + } + if !environmentUUID(c.Environment) { + return errors.New("install: --environment-id requires a canonical Environment ID") + } + for _, p := range []string{c.Workspace, c.Credential, c.CapabilityDirectory} { + if runtimefs.ValidateLocalPath(p) != nil { + return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") + } + } + info, err := os.Stat(c.Workspace) + if err != nil || !info.IsDir() { + return errors.New("install: --workspace requires an existing directory") + } + if _, _, err = executorCredential(c.Credential, c.Environment); err != nil { + return err + } + if c.ToolEnvironmentFile != "" { + var values map[string]string + if runtimefs.ValidateLocalPath(c.ToolEnvironmentFile) != nil || readNativeJSON(c.ToolEnvironmentFile, &values) != nil { + return errors.New("install: --tool-env-file requires an absolute JSON file containing string values") + } + } + return nil +} + +func runInstall(rc *runContext, args []string) error { + o, err := parseNativeInstall(rc, args) + if errors.Is(err, flag.ErrHelp) { + return nil + } + if err != nil { + return err + } + ctx, stop := daemonize.NotifyContext(context.Background()) + defer stop() + if runtimefs.ValidateLocalPath(o.Directory) != nil || runtimefs.ValidateLocalPath(o.Bundle) != nil { + return errors.New("install: --install-dir and --bundle-dir must be clean absolute directories") + } + selected, err := selectedNativeHarnesses(o.Harness) + if err != nil { + return err + } + o.Version = Version + if o.CapabilityDirectory == "" { + o.CapabilityDirectory = filepath.Join(o.Directory, "capabilities") + } + if err = validateNativeInstallation(o.nativeInstallation); err != nil { + return err + } + bundle, err := readNativeBundle(o.Bundle, selected) + if err != nil { + return err + } + held, unlock, err := lockNativeInstallation(o.Directory) + if err != nil { + return err + } + defer unlock() + var previous nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + wanted := o.nativeInstallation + wanted.Harnesses = nil + comparison := previous + comparison.Harnesses = nil + before, _ := json.Marshal(comparison) + after, _ := json.Marshal(wanted) + if !bytes.Equal(before, after) { + return errors.New("install: existing connection settings differ; additive installation cannot replace them") + } + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("install: cannot read existing installation") + } + all := append([]string{}, previous.Harnesses...) + for _, name := range selected { + if !slices.Contains(all, name) { + all = append(all, name) + } + } + slices.Sort(all) + // Verify existing components before adding any new one; never repair or + // upgrade an installed dependency as a side effect of adding a Harness. + if len(previous.Harnesses) > 0 { + if err = verifyNativeComponents(o.Directory, previous.Harnesses); err != nil { + return err + } + } + if err = installNativeBinary(o.Directory, len(previous.Harnesses) > 0); err != nil { + return err + } + for _, name := range append([]string{"node"}, selected...) { + if err = installNativeComponent(ctx, o.Bundle, o.Directory, name, bundle.Components[name]); err != nil { + return err + } + } + if err = probeNativeInstallation(ctx, o.Directory, all); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + o.Harnesses = all + raw, _ = json.MarshalIndent(o.nativeInstallation, "", " ") + if err = runtimefs.WritePrivateAtomic(held, "installation.json", raw); err != nil { + return err + } + fmt.Fprintln(rc.stdout, "Installation: ready; verified Harnesses:", all) + fmt.Fprintln(rc.stdout, "Daemon connection: not checked by install; run the installed oac-daemon start, then check Host connection in Core.") + fmt.Fprintln(rc.stdout, "Model configuration: not checked; configure the Session model provider in Core and send a Turn.") + return nil +} + +func verifyNativeComponents(root string, selected []string) error { + for _, name := range append([]string{"node"}, selected...) { + if _, ok := nativePins[name]; !ok { + return errors.New("installation contains an unsupported Harness") + } + c, err := componentReceipt(nativeComponentRoot(root, name)) + if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 || checkComponentFiles(nativeComponentRoot(root, name), c) != nil { + return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) + } + } + return nil +} + +func installNativeBinary(root string, existing bool) error { + exe, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Join(root, "bin") + if err = os.MkdirAll(dir, 0700); err != nil { + return err + } + dest := filepath.Join(dir, nativeExe("oac-daemon")) + digest := func(name string) (string, error) { + f, e := os.Open(name) + if e != nil { + return "", e + } + defer f.Close() + h := sha256.New() + _, e = io.Copy(h, f) + return hex.EncodeToString(h.Sum(nil)), e + } + want, err := digest(exe) + if err != nil { + return err + } + if got, e := digest(dest); e == nil { + if got != want { + return errors.New("install: existing daemon binary differs; in-place upgrades are unsupported") + } + return nil + } else if !errors.Is(e, os.ErrNotExist) { + return e + } + if existing { + return errors.New("install: existing daemon binary is missing; preserve the installation and reinstall separately") + } + in, err := os.Open(exe) + if err != nil { + return err + } + defer in.Close() + out, err := os.CreateTemp(dir, ".oac-daemon-") + if err != nil { + return err + } + defer os.Remove(out.Name()) + _, err = io.Copy(out, in) + if err == nil { + err = out.Chmod(0700) + } + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(out.Name(), dest) +} + +func runStart(rc *runContext, args []string) error { + ctx, stop := daemonize.NotifyContext(context.Background()) + defer stop() + flags := newFlagSet("start") + foreground := flags.Bool("foreground", false, "stay attached to this terminal") + if err := flags.Parse(args); err != nil { + return errors.New("start: invalid arguments") + } + if flags.NArg() != 0 { + return errors.New("start: unexpected positional arguments") + } + root, err := paths.Root() + if err != nil { + return err + } + held, unlock, err := lockNativeInstallation(root) + if err != nil { + return err + } + var config nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + err = decodeEnvironmentJSON(raw, &config) + } + if err == nil { + err = validateNativeInstallation(config) + } + if err == nil && len(config.Harnesses) == 0 { + err = errors.New("start: no installed Harnesses; rerun install with --harness") + } + if err == nil { + err = verifyNativeComponents(root, config.Harnesses) + } + if err == nil { + err = probeNativeInstallation(ctx, root, config.Harnesses) + } + unlock() + if err != nil { + return fmt.Errorf("start: installation unavailable or incompatible: %w", err) + } + previousKinds := rc.installedKinds + rc.installedKinds = nativeInstallationKinds(config.Harnesses) + defer func() { rc.installedKinds = previousKinds }() + values := nativeHarnessEnvironment(root, config.Harnesses) + values["OAC_RUNTIME_WORKSPACE"] = config.Workspace + values["OAC_RUNTIME_CAPABILITY_DIRECTORY"] = config.CapabilityDirectory + values["OAC_RUNTIME_TOOL_ENV_FILE"] = config.ToolEnvironmentFile + for key, value := range values { + if err = os.Setenv(key, value); err != nil { + return err + } + } + return runEnvironmentConnect(ctx, rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential) +} + +func useInstalledNativeHome() { + if os.Getenv("OAC_RUNTIME_HOME") != "" { + return + } + exe, err := os.Executable() + if err != nil { + return + } + if resolved, e := filepath.EvalSymlinks(exe); e == nil { + exe = resolved + } + if filepath.Base(filepath.Dir(exe)) != "bin" { + return + } + root := filepath.Dir(filepath.Dir(exe)) + if _, err = os.Stat(filepath.Join(root, "daemon", "installation.json")); err == nil { + _ = os.Setenv("OAC_RUNTIME_HOME", root) + } +} diff --git a/apps/parsar-daemon/internal/cli/native_install_input.go b/apps/parsar-daemon/internal/cli/native_install_input.go new file mode 100644 index 000000000..1ac4c9431 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_input.go @@ -0,0 +1,114 @@ +package cli + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +type nativeInstallOptions struct { + nativeInstallation + Directory, Bundle, Harness string + Interactive, NonInteractive bool +} + +func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { + var o nativeInstallOptions + flags := newFlagSet("install") + flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") + flags.StringVar(&o.Environment, "environment-id", "", "Environment ID from Core") + flags.StringVar(&o.Workspace, "workspace", "", "existing absolute workspace directory") + flags.StringVar(&o.Credential, "credential-file", "", "absolute executor credential JSON file (never the token)") + flags.StringVar(&o.CapabilityDirectory, "capability-directory", "", "capability snapshot destination") + flags.StringVar(&o.ToolEnvironmentFile, "tool-env-file", "", "optional tool environment JSON file") + flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default OAC_RUNTIME_HOME or ~/.oac)") + flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") + flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") + flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") + flags.BoolVar(&o.NonInteractive, "non-interactive", false, "require command-line options; never prompt") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(rc.stdout) + flags.PrintDefaults() + return o, flag.ErrHelp + } + // flag errors can contain supplied values, including accidental secrets. + return o, errors.New("install: invalid arguments; run install --help (use --credential-file for credentials)") + } + if flags.NArg() != 0 || (o.Interactive && o.NonInteractive) { + return o, errors.New("install: unexpected arguments or conflicting interaction modes") + } + if o.Directory == "" { + var err error + o.Directory, err = paths.Root() + if err != nil { + return o, err + } + } + if o.Bundle == "" { + exe, err := os.Executable() + if err != nil { + return o, err + } + o.Bundle = filepath.Dir(exe) + } + if len(args) == 0 && !o.NonInteractive { + if info, err := os.Stdin.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + o.Interactive = true + } + } + if o.Interactive { + input := rc.stdin + if input == nil { + input = os.Stdin + } + if err := promptNativeInstall(input, rc.stdout, &o); err != nil { + return o, err + } + } + return o, nil +} + +func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOptions) error { + r := bufio.NewReader(input) + // Only paths and connection identifiers are requested; tokens are read later + // from the private credential file, never entered or echoed by this prompt. + for _, p := range []struct { + label string + value *string + optional bool + }{ + {"Harnesses (comma-separated codex,claude,minimax)", &o.Harness, false}, + {"Installation directory (Enter uses current default)", &o.Directory, true}, + {"Distribution directory (Enter uses current default)", &o.Bundle, true}, + {"Environment remote URL", &o.Remote, false}, + {"Environment ID", &o.Environment, false}, + {"Existing workspace directory", &o.Workspace, false}, + {"Credential JSON file path (do not enter a token)", &o.Credential, false}, + {"Capability snapshot directory (optional)", &o.CapabilityDirectory, true}, + {"Tool environment JSON file path (optional)", &o.ToolEnvironmentFile, true}, + } { + if *p.value != "" && !p.optional { + continue + } + fmt.Fprint(output, p.label+": ") + line, err := r.ReadString('\n') + if err != nil { + return errors.New("install: interactive input ended; use --non-interactive with complete arguments") + } + value := strings.TrimSpace(line) + if value != "" { + *p.value = value + } else if !p.optional { + return errors.New("install: required interactive value missing") + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go b/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go new file mode 100644 index 000000000..d8a7e84da --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go @@ -0,0 +1,108 @@ +//go:build unix + +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestNativeInstallerInterruptHelper(t *testing.T) { + if os.Getenv("OAC_TEST_INSTALL_INTERRUPT") != "1" { + t.Skip("subprocess helper") + } + var args []string + if json.Unmarshal([]byte(os.Getenv("OAC_TEST_INSTALL_ARGS")), &args) != nil { + os.Exit(3) + } + err := runInstall(&runContext{stdout: io.Discard, stderr: io.Discard}, args) + if err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestNativeInstallationInterruptReapsProbe(t *testing.T) { + _, args, root, bundle := nativeInstallFixture(t) + marker := filepath.Join(t.TempDir(), "probe-pids") + var b nativeBundle + if err := readNativeJSON(filepath.Join(bundle, "bundle.json"), &b); err != nil { + t.Fatal(err) + } + // A real, separately grouped subprocess must be reaped by installation's + // signal handler, not just by a caller returning a synthetic probe error. + script := []byte("#!/bin/sh\nsleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$OAC_TEST_PROBE_PIDS\"\nwait\n") + sum := sha256.Sum256(script) + b.Components["node"] = nativeComponent{Version: nativePins["node"], Files: map[string]nativeFile{"bin/node": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} + dir := filepath.Join(nativeComponentRoot(bundle, "node"), "bin") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "node"), script, 0700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(b) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) + } + arguments, _ := json.Marshal(args) + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeInstallerInterruptHelper$") + cmd.Env = append(os.Environ(), "OAC_TEST_INSTALL_INTERRUPT=1", "OAC_TEST_INSTALL_ARGS="+string(arguments), "OAC_TEST_PROBE_PIDS="+marker) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { _ = cmd.Process.Kill() }() + var pids []string + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(marker); err == nil { + pids = strings.Fields(string(data)) + if len(pids) == 2 { + break + } + } + time.Sleep(20 * time.Millisecond) + } + if len(pids) != 2 { + t.Fatal("probe did not start") + } + defer func() { + for _, id := range pids { + pid, _ := strconv.Atoi(id) + p, _ := os.FindProcess(pid) + _ = p.Kill() + } + }() + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted installation succeeded") + } + case <-time.After(5 * time.Second): + t.Fatal("installer did not settle after interrupt") + } + for _, pid := range pids { + state, _ := exec.Command("ps", "-p", pid, "-o", "stat=").Output() + if s := strings.TrimSpace(string(state)); s != "" && !strings.HasPrefix(s, "Z") { + t.Fatal("interrupted installer left a running native process") + } + } + if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("interrupted installation committed settings") + } +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go new file mode 100644 index 000000000..0deb2f5b2 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -0,0 +1,269 @@ +package cli + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/google/uuid" +) + +func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { + t.Helper() + root := t.TempDir() + bundle := t.TempDir() + workspace := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + key := filepath.Join(root, "credential.json") + environment := uuid.NewString() + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) + if err := os.WriteFile(key, body, 0600); err != nil { + t.Fatal(err) + } + b := nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Components: map[string]nativeComponent{}} + for _, name := range []string{"node", "codex", "claude"} { + data := []byte("fixture " + name) + sum := sha256.Sum256(data) + b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} + dir := nativeComponentRoot(bundle, name) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "program"), data, 0700); err != nil { + t.Fatal(err) + } + } + raw, _ := json.Marshal(b) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) + } + old := probeNativeInstallation + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + t.Cleanup(func() { probeNativeInstallation = old }) + output := new(bytes.Buffer) + rc := &runContext{stdin: strings.NewReader("must not read"), stdout: output, stderr: output} + args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + return rc, args, root, bundle +} + +func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "daemon", "installation.json") + first, _ := os.ReadFile(file) + if err := runInstall(rc, args); err != nil { + t.Fatalf("compatible rerun: %v", err) + } + again, _ := os.ReadFile(file) + if !bytes.Equal(first, again) { + t.Fatal("reuse rewrote settings") + } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { + t.Fatal(err) + } + var installed nativeInstallation + if err := readNativeJSON(file, &installed); err != nil { + t.Fatal(err) + } + if strings.Join(installed.Harnesses, ",") != "claude,codex" { + t.Fatal("lost or failed to add Harness") + } + if strings.Contains(rc.stdout.(*bytes.Buffer).String(), "private-test-credential") { + t.Fatal("credential leaked") + } + if err := runInstall(rc, append(args, "--workspace", t.TempDir())); err == nil { + t.Fatal("replaced connection settings") + } + installed.Version = "old-version" + raw, _ := json.Marshal(installed) + _ = os.WriteFile(file, raw, 0600) + if err := runInstall(rc, args); err == nil { + t.Fatal("accepted historical installation") + } + if err := runStart(rc, []string{"--foreground"}); err == nil { + t.Fatal("started historical installation") + } + if got, _ := os.ReadFile(file); !bytes.Equal(got, raw) { + t.Fatal("modified historical state") + } +} + +func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "daemon", "installation.json") + before, _ := os.ReadFile(file) + probeNativeInstallation = func(context.Context, string, []string) error { return errors.New("synthetic unavailable") } + if err := runInstall(rc, append(args, "--harness", "claude")); err == nil { + t.Fatal("published failed installation") + } + if after, _ := os.ReadFile(file); !bytes.Equal(before, after) { + t.Fatal("failed addition changed settings") + } + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { + t.Fatalf("could not reuse complete unpublished component: %v", err) + } + // Source corruption cannot affect an existing component, and cannot be used + // to repair a modified installed one silently. + _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired modified executable") + } + if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { + t.Fatal("overwrote installed data") + } + _ = bundle +} + +func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { + rc, _, root, _ := nativeInstallFixture(t) + for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}} { + err := runInstall(rc, args) + if err == nil { + t.Fatal("accepted incomplete input") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "private-test-credential") { + t.Fatal("echoed sensitive argument") + } + } + if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("incomplete input wrote installation") + } +} + +func TestNativeInstallationInteractiveMultipleHarnesses(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + var o nativeInstallOptions + // Prompt and parameter paths feed the same installer options. + o.Directory = root + o.Bundle = bundle + // Existing connection fields need no second interactive input. + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "--remote": + o.Remote = args[i+1] + case "--environment-id": + o.Environment = args[i+1] + case "--workspace": + o.Workspace = args[i+1] + case "--credential-file": + o.Credential = args[i+1] + } + } + input := "codex,claude\n\n\n\n\n" + if err := promptNativeInstall(strings.NewReader(input), rc.stdout, &o); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, append(args, "--harness", o.Harness)); err != nil { + t.Fatal(err) + } + var c nativeInstallation + _ = readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &c) + if len(c.Harnesses) != 2 { + t.Fatal("multi-selection not installed") + } + if err := promptNativeInstall(strings.NewReader(""), rc.stdout, &nativeInstallOptions{}); err == nil { + t.Fatal("EOF accepted") + } +} + +func TestNativeInstallationLockAndManifestContainment(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + _, unlock, err := lockNativeInstallation(root) + if err != nil { + t.Fatal(err) + } + if err = runInstall(rc, args); err == nil { + t.Fatal("concurrent installation accepted") + } + unlock() + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + c := b.Components["codex"] + c.Files["../escaped"] = c.Files["program"] + b.Components["codex"] = c + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err = runInstall(rc, args); err == nil { + t.Fatal("accepted manifest traversal") + } + if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("manifest validation happened after mutation") + } +} + +func TestNativeInstallationPlatformMismatchAndUnsupportedSelection(t *testing.T) { + rc, args, _, bundle := nativeInstallFixture(t) + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + b.OS = "other" + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err := runInstall(rc, args); err == nil { + t.Fatal("foreign bundle accepted") + } + if _, err := selectedNativeHarnesses("anything"); err == nil { + t.Fatal("unknown Harness accepted") + } + if runtime.GOOS == "windows" { + if _, err := selectedNativeHarnesses("minimax"); err == nil { + t.Fatal("unsupported Windows MiniMax accepted") + } + } +} + +func TestNativeInstallationDoesNotRepairMissingDaemon(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + binary := filepath.Join(root, "bin", nativeExe("oac-daemon")) + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired a modified installation") + } + if _, err := os.Stat(binary); !errors.Is(err, os.ErrNotExist) { + t.Fatal("recreated a removed daemon") + } +} + +func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"installed", "missing-component"} { + t.Run(mode, func(t *testing.T) { + if mode == "missing-component" { + if err := os.Remove(filepath.Join(nativeComponentRoot(root, "codex"), "program")); err != nil { + t.Fatal(err) + } + } + for _, connection := range [][]string{ + nil, + {"--remote", "ws://127.0.0.1:1/api/v1/agent-daemon/ws"}, + {"--url", "http://127.0.0.1:1", "--token", "private-test-token"}, + } { + err := runConnect(rc, connection) + if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") || strings.Contains(err.Error(), "private-test-token") { + t.Fatal("connect bypassed native installation validation", err) + } + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go b/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go new file mode 100644 index 000000000..316498fda --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go @@ -0,0 +1,93 @@ +//go:build unix + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNativeStartInterruptHelper(t *testing.T) { + if os.Getenv("OAC_TEST_START_INTERRUPT") != "1" { + t.Skip("subprocess helper") + } + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + if err := runStart(&runContext{stdout: os.Stdout, stderr: os.Stderr}, nil); err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestNativeStartInterruptCancelsEnrollment(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + requested := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requested) + <-release + var config nativeInstallation + if err := readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &config); err != nil { + return + } + _ = json.NewEncoder(w).Encode(environmentEnrollment{ + DeviceID: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", + SessionID: "bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb", + EnvironmentID: config.Environment, + WorkspaceDirectory: config.Workspace, + }) + })) + defer server.Close() + defer close(release) + for i := range args { + if args[i] == "--remote" { + args[i+1] = "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws" + } + } + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeStartInterruptHelper$") + cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1") + output := new(bytes.Buffer) + cmd.Stdout, cmd.Stderr = output, output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill() }() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-requested: + case <-time.After(10 * time.Second): + t.Fatal("start did not reach enrollment") + } + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted startup succeeded") + } + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatal("start ignored interrupt while enrollment was pending") + } + if strings.Contains(output.String(), "backgrounded") { + t.Fatal("interrupted startup spawned a daemon") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "default", "connect.pid")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("interrupted startup published a background process") + } +} diff --git a/apps/parsar-daemon/internal/cli/retired_configuration.go b/apps/parsar-daemon/internal/cli/retired_configuration.go index 712aea7c8..d5bbf5e97 100644 --- a/apps/parsar-daemon/internal/cli/retired_configuration.go +++ b/apps/parsar-daemon/internal/cli/retired_configuration.go @@ -34,17 +34,13 @@ var renamedRuntimeSettings = map[string]string{ "PARSAR_PI_API_KEY": "OAC_RUNTIME_PI_API_KEY", "PARSAR_PI_BIN": "OAC_RUNTIME_PI_BIN", "PARSAR_RUNTIME_ALLOWED_DOMAINS": "OAC_RUNTIME_ALLOWED_DOMAINS", - "PARSAR_RUNTIME_DIRECTORY_HELPER": "OAC_RUNTIME_DIRECTORY_HELPER", "PARSAR_RUNTIME_ENVIRONMENT_ID": "OAC_RUNTIME_ENVIRONMENT_ID", - "PARSAR_RUNTIME_EXPORT_HELPER": "OAC_RUNTIME_EXPORT_HELPER", "PARSAR_RUNTIME_NETWORK_ACCESS": "OAC_RUNTIME_NETWORK_ACCESS", "PARSAR_RUNTIME_SESSION_ID": "OAC_RUNTIME_SESSION_ID", - "PARSAR_RUNTIME_STAGING": "OAC_RUNTIME_STAGING", "PARSAR_RUNTIME_SYSTEM_PACKAGES": "OAC_RUNTIME_SYSTEM_PACKAGES", "PARSAR_RUNTIME_TOOL_ENV": "OAC_RUNTIME_TOOL_ENV", "PARSAR_RUNTIME_TOOL_SCRATCH": "OAC_RUNTIME_TOOL_SCRATCH", "PARSAR_RUNTIME_WORKSPACE": "OAC_RUNTIME_WORKSPACE", - "PARSAR_RUNTIME_WRITE_HELPER": "OAC_RUNTIME_WRITE_HELPER", "PARSAR_LOG_LEVEL": "OAC_LOG_LEVEL", "PARSAR_LOG_FORMAT": "OAC_LOG_FORMAT", "PARSAR_LOG_ADD_SOURCE": "OAC_LOG_ADD_SOURCE", diff --git a/apps/parsar-daemon/internal/cli/retired_configuration_test.go b/apps/parsar-daemon/internal/cli/retired_configuration_test.go index 740c8640a..8ba18fdc7 100644 --- a/apps/parsar-daemon/internal/cli/retired_configuration_test.go +++ b/apps/parsar-daemon/internal/cli/retired_configuration_test.go @@ -11,7 +11,7 @@ func TestRetiredRuntimeSettingsRejectBeforeEverySubcommand(t *testing.T) { t.Setenv("PARSAR_UNKNOWN_RUNTIME_SETTING", "private-canary") t.Setenv("PARSAR_CODEX_BIN", "") t.Setenv("PARSAR_MCP_BEARER_EXAMPLE", "private-bearer") - for _, command := range []string{"version", "--help", "connect", "runtime-capabilities"} { + for _, command := range []string{"version", "--help", "connect", "runtime-mcp-exec"} { stdout, stderr, err := runArgv(t, command) if err == nil || stdout != "" || stderr != "" { t.Fatalf("%s ran before retirement validation: %q %q %v", command, stdout, stderr, err) diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 85d0ac474..c8189653a 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -19,23 +19,26 @@ type command struct { run func(ctx *runContext, args []string) error } -// runContext bundles the streams a command writes to. Tests inject -// buffers; production uses the OS streams. +// runContext carries command I/O and an optional installed Harness selection. +// Tests inject streams; production uses the OS streams. type runContext struct { - stdout io.Writer - stderr io.Writer + installedKinds map[string]bool + stdin io.Reader + stdout io.Writer + stderr io.Writer } func defaultRunContext() *runContext { - return &runContext{stdout: os.Stdout, stderr: os.Stderr} + return &runContext{stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr} } // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ + {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, + {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, - {name: "runtime-capabilities", summary: "Install frozen capabilities in the packaged Runtime", run: runRuntimeCapabilities}, - {name: "runtime-mcp-exec", summary: "Execute installed MCP inside the packaged Runtime sandbox", run: runRuntimeMCP}, + {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, @@ -51,6 +54,7 @@ func Execute(argv []string) error { } func execute(ctx *runContext, argv []string) error { + useInstalledNativeHome() if err := validateRuntimeConfiguration(); err != nil { return err } diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 0bb8d576b..c27176d35 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -60,16 +60,17 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ - "resume": false, - "runtime-capabilities": false, - "runtime-mcp-exec": false, - "placement": false, - "connect": false, - "status": false, - "stop": false, - "logs": false, - "logout": false, - "version": false, + "install": false, + "start": false, + "resume": false, + "runtime-mcp-exec": false, + "placement": false, + "connect": false, + "status": false, + "stop": false, + "logs": false, + "logout": false, + "version": false, } for _, c := range commands { if _, ok := want[c.name]; !ok { @@ -84,3 +85,14 @@ func TestSubcommandsAreRegistered(t *testing.T) { } } } + +func TestRemovedCapabilityHelperIsNotACommand(t *testing.T) { + help, _, err := runArgv(t, "--help") + if err != nil || strings.Contains(help, "runtime-capabilities") { + t.Fatal("removed helper remains advertised", err) + } + _, _, err = runArgv(t, "runtime-capabilities") + if err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatal("removed helper still dispatches", err) + } +} diff --git a/apps/parsar-daemon/internal/cli/runtime_capabilities.go b/apps/parsar-daemon/internal/cli/runtime_capabilities.go deleted file mode 100644 index ac17058ba..000000000 --- a/apps/parsar-daemon/internal/cli/runtime_capabilities.go +++ /dev/null @@ -1,56 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" -) - -// This packaged helper is invoked by the existing Core initializer. It has no -// daemon connection, credentials, scheduling or retry behavior. -func runRuntimeCapabilities(ctx *runContext, args []string) error { - if len(args) != 0 { - return agentcapabilities.ErrInvalid - } - var request agentcapabilities.Operation - decoder := json.NewDecoder(io.LimitReader(os.Stdin, 32<<20)) - decoder.DisallowUnknownFields() - if decoder.Decode(&request) != nil || request.Version != 1 { - return agentcapabilities.ErrInvalid - } - var extra any - if decoder.Decode(&extra) != io.EOF { - return agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot("/environment") - if err != nil { - return agentcapabilities.ErrInvalid - } - defer root.Close() - installed, err := root.OpenRoot("initialization/capabilities") - if err != nil { - return agentcapabilities.ErrInvalid - } - defer installed.Close() - switch request.Action { - case "plugin": - err = agentcapabilities.InstallPlugin(installed, request.Slot, request.Archive, request.Plugin) - case "finalize": - workspace, openErr := root.OpenRoot("workspace") - if openErr != nil { - return agentcapabilities.ErrInvalid - } - defer workspace.Close() - err = agentcapabilities.Finalize(workspace, installed, request.Sources) - default: - err = agentcapabilities.ErrInvalid - } - if err != nil { - return agentcapabilities.ErrInvalid - } - _, err = fmt.Fprintln(ctx.stdout, `{"version":1,"outcome":"completed"}`) - return err -} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go index b8f6e7483..bca7cab0d 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "sort" "strings" @@ -20,13 +21,12 @@ type mcpInvocation struct { env []string } -// runRuntimeMCP executes only inside the packaged initializer's sandbox. It -// never consumes MCP stdin, opens a daemon connection or owns a child process. +// runRuntimeMCP forwards stdio to the selected installed MCP server. func runRuntimeMCP(_ *runContext, args []string) error { - if len(args) != 2 { + if len(args) != 3 || args[0] == "/" || agentcapabilities.ValidateLocalDirectories([]string{args[0]}) != nil { return errRuntimeMCP } - root, err := os.OpenRoot(agentcapabilities.Directory) + root, err := os.OpenRoot(args[0]) if err != nil { return errRuntimeMCP } @@ -35,18 +35,21 @@ func runRuntimeMCP(_ *runContext, args []string) error { if err != nil { return errRuntimeMCP } - values, err := localworkspace.ReadToolEnvironment() + values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { return errRuntimeMCP } - invocation, err := resolveMCPInvocation(manifest, args[0], args[1], values) + invocation, err := resolveMCPInvocation(manifest, args[0], args[1], args[2], values) if err != nil { return errRuntimeMCP } return execRuntimeMCP(invocation) } -func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, values map[string]string) (mcpInvocation, error) { +func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, pkg, name string, values map[string]string) (mcpInvocation, error) { + if installationRoot == "/" || agentcapabilities.ValidateLocalDirectories([]string{installationRoot}) != nil { + return mcpInvocation{}, errRuntimeMCP + } for _, installed := range manifest.MCP { server := installed.Server if installed.PackageRoot != pkg || server.Name != name { @@ -55,24 +58,26 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, if server.Type != "stdio" { return mcpInvocation{}, errRuntimeMCP } - // Defaults locate installed dependencies. Other user values require an - // explicit env_vars declaration; the native launcher's env is never read. - env := map[string]string{ - "PATH": "/environment/packages/npm/bin:/environment/packages/python/bin:/usr/local/bin:/usr/bin:/bin", - "PYTHONPATH": "/environment/packages/python", - "HOME": "/tmp", - "LANG": "C.UTF-8", + // MCP runs under the same user environment as the daemon. Explicit + // capability variables override inherited values. + env := map[string]string{} + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[mcpEnvironmentKey(key)] = value + } } + for _, key := range server.EnvVars { value, exists := values[key] if !exists || strings.ContainsRune(value, 0) { return mcpInvocation{}, errRuntimeMCP } - env[key] = value + env[mcpEnvironmentKey(key)] = value } cwd := server.CWD if !filepath.IsAbs(cwd) { - cwd = filepath.Join(agentcapabilities.Directory, installed.PackageRoot, cwd) + cwd = filepath.Join(installationRoot, installed.PackageRoot, cwd) } result := mcpInvocation{command: server.Command, args: append([]string{server.Command}, server.Args...), cwd: cwd} for key, value := range env { @@ -83,3 +88,26 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, } return mcpInvocation{}, errRuntimeMCP } + +func mcpEnvironmentKey(key string) string { + if runtime.GOOS == "windows" { + return strings.ToUpper(key) + } + return key +} + +// This command is a dedicated MCP launcher process. Apply its final environment +// before executable lookup so PATH and npm selection agree on every platform. +func configureMCPProcess(invocation mcpInvocation) error { + if os.Chdir(invocation.cwd) != nil { + return errRuntimeMCP + } + os.Clearenv() + for _, entry := range invocation.env { + key, value, _ := strings.Cut(entry, "=") + if os.Setenv(key, value) != nil { + return errRuntimeMCP + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go b/apps/parsar-daemon/internal/cli/runtime_mcp_other.go deleted file mode 100644 index e8fb291ad..000000000 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !linux - -package cli - -func execRuntimeMCP(mcpInvocation) error { return errRuntimeMCP } diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go index 39b43ec54..ec8366c79 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go @@ -8,22 +8,22 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" ) -func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { +func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", }}}} t.Setenv("SELECTED", "private-native-value") t.Setenv("NATIVE_ONLY", "private-native-value") - got, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) - if err != nil || got.command != "python3" || got.cwd != agentcapabilities.Directory+"/plugins/0/server" || + got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) + if err != nil || got.command != "python3" || got.cwd != "/private/runtime/capabilities/plugins/0/server" || !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || - slices.Contains(got.env, "UNDECLARED=not-injected") || slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { - t.Fatalf("incorrect isolated invocation: %+v %v", got, err) + slices.Contains(got.env, "UNDECLARED=not-injected") || !slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { + t.Fatalf("incorrect invocation configuration: %v", err) } - if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{}); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{}); err == nil { t.Fatal("missing user value fell back to native environment") } - if _, err := resolveMCPInvocation(manifest, "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { t.Fatal("undeclared package selected") } } @@ -32,12 +32,20 @@ func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "local", Type: "stdio", Command: "python3", CWD: "/workspace", }}}} - got, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil) + got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) if err != nil || got.cwd != "/workspace" { - t.Fatalf("absolute cwd changed: %+v %v", got, err) + t.Fatalf("absolute cwd changed: %v", err) } manifest.MCP[0].Server.Type = "http" - if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil); err == nil { t.Fatal("HTTP configuration treated as a process") } } + +func TestRuntimeMCPRejectsUncleanInstallationRoot(t *testing.T) { + for _, root := range []string{"", "/", "relative", "/tmp/../private", "/tmp/root/", "/tmp/line\n"} { + if _, err := resolveMCPInvocation(agentcapabilities.Manifest{}, root, "plugins/0", "local", nil); err == nil { + t.Fatal("invalid root accepted", root) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go similarity index 54% rename from apps/parsar-daemon/internal/cli/runtime_mcp_linux.go rename to apps/parsar-daemon/internal/cli/runtime_mcp_unix.go index 73396f8dc..7c74a3b64 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go @@ -1,24 +1,15 @@ -//go:build linux +//go:build !windows package cli import ( - "os" "os/exec" - "strings" "syscall" ) func execRuntimeMCP(invocation mcpInvocation) error { - if os.Chdir(invocation.cwd) != nil { - return errRuntimeMCP - } - os.Clearenv() - for _, entry := range invocation.env { - key, value, _ := strings.Cut(entry, "=") - if os.Setenv(key, value) != nil { - return errRuntimeMCP - } + if err := configureMCPProcess(invocation); err != nil { + return err } command, err := exec.LookPath(invocation.command) if err != nil { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go new file mode 100644 index 000000000..35606bfd7 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package cli + +import ( + "os" + "os/exec" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" +) + +func execRuntimeMCP(invocation mcpInvocation) error { + if err := configureMCPProcess(invocation); err != nil { + return err + } + binary, args, err := localworkspace.ResolvePackageManagerCommand(invocation.command, invocation.args[1:]) + if err != nil { + return errRuntimeMCP + } + command := exec.Command(binary, args...) + command.Dir, command.Env = invocation.cwd, invocation.env + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + return errRuntimeMCP + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go new file mode 100644 index 000000000..0d1179240 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go @@ -0,0 +1,179 @@ +//go:build windows + +package cli + +import ( + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestWindowsRuntimeMCPPackageManagers(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("native test requires installed Node.js", err) + } + workspace := filepath.Join(t.TempDir(), "local stdio project") + bin := filepath.Join(workspace, "node_modules", ".bin") + pkg := filepath.Join(workspace, "node_modules", "oac-stdio-fixture") + if err = os.MkdirAll(bin, 0700); err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(pkg, 0700); err != nil { + t.Fatal(err) + } + fixture := filepath.Join(pkg, "fixture.cjs") + files := map[string]string{ + filepath.Join(workspace, "package.json"): `{"name":"offline-test","version":"1.0.0","private":true}`, + filepath.Join(pkg, "package.json"): `{"name":"oac-stdio-fixture","version":"1.0.0","bin":{"oac-stdio-fixture":"fixture.cjs"}}`, + fixture: `let input="";process.stdin.setEncoding("utf8");process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`, + filepath.Join(bin, "oac-stdio-fixture.cmd"): "@\"" + node + "\" \"" + fixture + "\" %*\r\n", + } + for path, body := range files { + if err = os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + // Each invocation uses only this installed local bin, with an empty offline cache. + environment := append(os.Environ(), "npm_config_offline=true", "npm_config_yes=false", "npm_config_cache="+t.TempDir(), "OAC_MCP_FIXTURE_VALUE=selected") + for _, name := range []string{"npm", "npx"} { + absolute, err := exec.LookPath(name + ".cmd") + if err != nil { + t.Fatal("native test requires installed npm/npx", err) + } + for _, command := range []string{name, name + ".cmd", absolute} { + t.Run(command, func(t *testing.T) { + args := []string{command} + if name == "npm" { + args = append(args, "exec") + } + args = append(args, "--offline", "--yes=false", "--", "oac-stdio-fixture", "two words") + checkWindowsMCPStdio(t, mcpInvocation{command: command, args: args, cwd: workspace, env: environment}, []string{"two words"}) + }) + } + } + checkWindowsMCPStdio(t, mcpInvocation{command: node, args: []string{node, fixture, "ordinary exe", "literal & %PATH%"}, cwd: workspace, env: environment}, []string{"ordinary exe", "literal & %PATH%"}) +} + +func checkWindowsMCPStdio(t *testing.T, invocation mcpInvocation, wantArgs []string) { + t.Helper() + previousEnv := os.Environ() + previousCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + os.Clearenv() + for _, entry := range previousEnv { + key, value, ok := strings.Cut(entry, "=") + if ok { + _ = os.Setenv(key, value) + } + } + _ = os.Chdir(previousCWD) + }() + input, err := os.CreateTemp(t.TempDir(), "input") + if err != nil { + t.Fatal(err) + } + defer input.Close() + output, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + defer output.Close() + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + if err != nil { + t.Fatal(err) + } + defer stderr.Close() + if _, err = input.WriteString("local stdio payload\n"); err != nil { + t.Fatal(err) + } + if _, err = input.Seek(0, 0); err != nil { + t.Fatal(err) + } + stdin, stdout, oldErr := os.Stdin, os.Stdout, os.Stderr + os.Stdin, os.Stdout, os.Stderr = input, output, stderr + defer func() { os.Stdin, os.Stdout, os.Stderr = stdin, stdout, oldErr }() + if err = execRuntimeMCP(invocation); err != nil { + detail, _ := os.ReadFile(stderr.Name()) + t.Fatalf("native local MCP failed: %v: %s", err, detail) + } + raw, err := os.ReadFile(output.Name()) + if err != nil { + t.Fatal(err) + } + var got struct { + Input, CWD, Value string + Args []string + } + if json.Unmarshal(raw, &got) != nil || got.Input != "local stdio payload\n" || filepath.Clean(got.CWD) != filepath.Clean(invocation.cwd) || got.Value != "selected" || !slices.Equal(got.Args, wantArgs) { + t.Fatalf("stdio invocation changed: %s", raw) + } +} + +func TestWindowsRuntimeMCPExplicitPath(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + body, err := os.ReadFile(node) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "selected-node.exe"), body, 0700); err != nil { + t.Fatal(err) + } + fixture := filepath.Join(directory, "stdio.cjs") + if err = os.WriteFile(fixture, []byte(`let input="";process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("Path", os.Getenv("PATH")) + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "test", Type: "stdio", Command: "selected-node", Args: []string{fixture}, CWD: directory, EnvVars: []string{"PATH", "OAC_MCP_FIXTURE_VALUE"}}}}} + invocation, err := resolveMCPInvocation(manifest, directory, "plugins/0", "test", map[string]string{"PATH": directory, "OAC_MCP_FIXTURE_VALUE": "selected"}) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, entry := range invocation.env { + key, value, _ := strings.Cut(entry, "=") + if strings.EqualFold(key, "PATH") { + count++ + if value != directory { + t.Fatal("explicit PATH lost") + } + } + } + if count != 1 { + t.Fatal("duplicate PATH variants", count) + } + checkWindowsMCPStdio(t, invocation, nil) + // npm resolution must also use the selected installation, not ambient npm. + npmBin := filepath.Join(directory, "node_modules", "npm", "bin") + if err = os.MkdirAll(npmBin, 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "npm.cmd"), []byte("@exit /b 1"), 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "node.exe"), body, 0700); err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(fixture) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(npmBin, "npm-cli.js"), source, 0600); err != nil { + t.Fatal(err) + } + invocation.command, invocation.args = "npm", []string{"npm", "selected installation"} + checkWindowsMCPStdio(t, invocation, []string{"selected installation"}) +} diff --git a/apps/parsar-daemon/internal/cli/skill_upload.go b/apps/parsar-daemon/internal/cli/skill_upload.go index 935b35cd3..c77b0fefe 100644 --- a/apps/parsar-daemon/internal/cli/skill_upload.go +++ b/apps/parsar-daemon/internal/cli/skill_upload.go @@ -12,16 +12,7 @@ import ( func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - env, _ := req.AgentOptions["env"].(map[string]any) - if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { - env = maps.Clone(env) - env["PARSAR_SERVER_URL"] = serverURL - if executable, err := os.Executable(); err == nil { - addCompanionCLIPath(env, filepath.Dir(executable)) - } - req.AgentOptions = maps.Clone(req.AgentOptions) - req.AgentOptions["env"] = env - } + req = skillUploadRequest(req, serverURL) return factory(ctx, req, out) } } @@ -37,3 +28,17 @@ func addCompanionCLIPath(env map[string]any, dir string) { } env["PATH"] = dir + string(os.PathListSeparator) + path } + +func skillUploadRequest(req proto.PromptRequestPayload, serverURL string) proto.PromptRequestPayload { + env, _ := req.AgentOptions["env"].(map[string]any) + if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { + env = maps.Clone(env) + env["PARSAR_SERVER_URL"] = serverURL + if executable, err := os.Executable(); err == nil { + addCompanionCLIPath(env, filepath.Dir(executable)) + } + req.AgentOptions = maps.Clone(req.AgentOptions) + req.AgentOptions["env"] = env + } + return req +} diff --git a/apps/parsar-daemon/internal/cli/status.go b/apps/parsar-daemon/internal/cli/status.go index fff3cfda9..dc1e8d4e0 100644 --- a/apps/parsar-daemon/internal/cli/status.go +++ b/apps/parsar-daemon/internal/cli/status.go @@ -32,7 +32,7 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { case errors.Is(err, auth.ErrNotPaired): - fmt.Fprintln(ctx.stdout, "paired : no legacy profile (use `oac-daemon connect --url ... --token ...`)") + fmt.Fprintln(ctx.stdout, "paired : no saved pairing profile; check Host connection in Core for a self-hosted Runtime") case err != nil: fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) default: diff --git a/apps/parsar-daemon/internal/cli/stop.go b/apps/parsar-daemon/internal/cli/stop.go index 125cb4c49..e58f32301 100644 --- a/apps/parsar-daemon/internal/cli/stop.go +++ b/apps/parsar-daemon/internal/cli/stop.go @@ -9,11 +9,8 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" ) -// runStop sends SIGTERM to the pid in ~/.oac/daemon// -// connect.pid, escalates to SIGKILL after killTimeout, then removes -// the pidfile. Idempotent: missing / stale pidfile cleans up and -// exits 0 — the user's goal is "no background daemon" and that holds -// either way. +// runStop requests cleanup from the recorded daemon identity. A timeout keeps +// the ownership record; only confirmed exit permits removing it. func runStop(ctx *runContext, args []string) error { fs := newFlagSet("stop") profile := fs.String("profile", paths.DefaultProfile, "profile name to stop") @@ -44,13 +41,10 @@ func runStop(ctx *runContext, args []string) error { return fmt.Errorf("stop: read pidfile: %w", err) } - fmt.Fprintf(ctx.stdout, "oac-daemon: sending SIGTERM to pid=%d\n", pid) - if err := daemonize.SignalAndWait(pid, killTimeout); err != nil { + fmt.Fprintf(ctx.stdout, "oac-daemon: requesting stop for pid=%d\n", pid) + if err := daemonize.StopPIDFile(pidPath, killTimeout); err != nil { return fmt.Errorf("stop: signal: %w", err) } - if err := daemonize.RemovePIDFile(pidPath); err != nil { - return fmt.Errorf("stop: remove pidfile: %w", err) - } fmt.Fprintln(ctx.stdout, "oac-daemon: stopped") return nil } diff --git a/apps/parsar-daemon/internal/cli/suspend_control.go b/apps/parsar-daemon/internal/cli/suspend_control.go index 745ffa172..eedb7a545 100644 --- a/apps/parsar-daemon/internal/cli/suspend_control.go +++ b/apps/parsar-daemon/internal/cli/suspend_control.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/suspend_process_other.go b/apps/parsar-daemon/internal/cli/suspend_process_other.go index cdf1bc1f4..42073245d 100644 --- a/apps/parsar-daemon/internal/cli/suspend_process_other.go +++ b/apps/parsar-daemon/internal/cli/suspend_process_other.go @@ -2,11 +2,37 @@ package cli -import "errors" +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "os" +) -func suspendProcessStart(int) (string, error) { - return "", errors.New("hosted suspension requires Linux") +const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" + +type suspendIdentity struct { + EnvironmentID string + SuspendID string +} +type suspendControl struct { + identity suspendIdentity + signal chan os.Signal + lastResumed *proto.EnvironmentSuspendPayload +} + +func newSuspendControl() (*suspendControl, error) { + if os.Getenv(suspendControlEnv) != "" { + return nil, errors.New("hosted suspension requires Linux") + } + return nil, nil +} +func (c *suspendControl) Arm(proto.EnvironmentSuspendPayload) error { + return errors.New("hosted suspension requires Linux") } -func signalSuspendedProcess(suspendIdentity) error { +func (c *suspendControl) Wait(context.Context) error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Disarm() error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Close() {} +func runResume(*runContext, []string) error { return errors.New("hosted suspension requires Linux") } diff --git a/apps/parsar-daemon/internal/contracttest/wire_test.go b/apps/parsar-daemon/internal/contracttest/wire_test.go new file mode 100644 index 000000000..d2b38ca98 --- /dev/null +++ b/apps/parsar-daemon/internal/contracttest/wire_test.go @@ -0,0 +1,374 @@ +// Package contracttest exercises the shared protocol over an actual WebSocket, +// using the production gateway, transport and dispatcher with a controlled adapter. +package contracttest + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "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/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" +) + +const credential = "synthetic-contract-credential" + +type credentialStore struct{} + +func (credentialStore) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { + return device.Credential{ID: "runtime", WorkspaceID: "tenant", Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: device.HashCredential(credential)}, true, nil +} + +func newGateway(t *testing.T) (*gateway.Registry, string) { + t.Helper() + reg := gateway.NewRegistry() + handler := gateway.NewHandler(gateway.HandlerConfig{Registry: reg, Authenticator: gateway.NewAuthenticator(credentialStore{})}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + t.Cleanup(server.Close) + return reg, "ws" + strings.TrimPrefix(server.URL, "http") +} + +func dial(t *testing.T, endpoint, version string) (*transport.Conn, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + return transport.Dial(ctx, transport.DialOptions{WSURL: endpoint, DeviceID: "runtime", Credential: credential, DaemonVersion: version}) +} + +func TestWireContractRejectsVersionMismatchWithoutReconnect(t *testing.T) { + _, endpoint := newGateway(t) + for _, version := range []string{"0.7.0", "0.8.99", "0.8.", proto.Version + "-dev", proto.Version + "+build"} { + t.Run(version, func(t *testing.T) { + attempts := 0 + _, err := transport.Reconnect(t.Context(), func(context.Context) (*transport.Conn, error) { + attempts++ + return dial(t, endpoint, version) + }, transport.DefaultBackoff, nil) + if !errors.Is(err, transport.ErrIncompatibleVersion) || !errors.Is(err, transport.ErrPermanent) || attempts != 1 { + t.Fatalf("mismatch must stop after one attempt: attempts=%d error=%v", attempts, err) + } + }) + } +} + +type wireFixture struct { + core *gateway.Session + conn *transport.Conn + router *dispatch.Router + reg *gateway.Registry + endpoint string + stopped chan struct{} + shutdownErr error +} + +func connectFixture(t *testing.T, factory agent.ExecutorFactory) *wireFixture { + t.Helper() + reg, endpoint := newGateway(t) + conn, err := dial(t, endpoint, proto.Version) + if err != nil { + t.Fatal(err) + } + core, err := reg.WaitForDevice(t.Context(), "runtime", 3*time.Second) + if err != nil { + t.Fatal(err) + } + kinds := agent.NewRegistry() + kinds.RegisterKind(proto.SupportedAgentKind{Kind: "contract", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("prepared execution must not use prompt_request") + }) + kinds.RegisterExecutor("contract", factory) + router, err := dispatch.New(dispatch.Config{Registry: kinds, Sender: conn}) + if err != nil { + t.Fatal(err) + } + f := &wireFixture{core: core, conn: conn, router: router, reg: reg, endpoint: endpoint, stopped: make(chan struct{})} + go func() { + for env := range conn.Recv() { + if err := router.Handle(t.Context(), env); err != nil { + t.Errorf("dispatch: %v", err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + f.shutdownErr = router.Shutdown(ctx) + close(f.stopped) + }() + t.Cleanup(func() { + conn.Close() + select { + case <-f.stopped: + if f.shutdownErr != nil { + t.Error(f.shutdownErr) + } + case <-time.After(4 * time.Second): + t.Error("Runtime shutdown did not complete") + } + core.Close("test finished") + }) + return f +} + +func (f *wireFixture) send(t *testing.T, kind, id string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := f.core.Send(ctx, env); err != nil { + t.Fatal(err) + } +} + +func receive(t *testing.T, sub *gateway.Subscription) proto.Envelope { + t.Helper() + select { + case env, ok := <-sub.Events: + if !ok { + t.Fatalf("subscription closed: %v", sub.Err()) + } + return env + case <-time.After(3 * time.Second): + t.Fatal("no protocol response") + } + return proto.Envelope{} +} + +func status(t *testing.T, sub *gateway.Subscription, want string) proto.PreparationStatusPayload { + t.Helper() + for { + env := receive(t, sub) + var got proto.PreparationStatusPayload + if env.Type != proto.TypePreparationStatus || env.DecodePayload(&got) != nil { + t.Fatalf("invalid preparation response: %+v", env) + } + if got.State == want { + return got + } + if got.State != "preparing" && got.State != "starting" { + t.Fatalf("preparation state=%s, want=%s", got.State, want) + } + } +} + +func (f *wireFixture) prepare(t *testing.T) (*gateway.Subscription, proto.PreparationStatusPayload) { + t.Helper() + sub, err := f.core.SubscribePreparation("prepare") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{ + SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "contract", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}, + }) + return sub, status(t, sub, "ready") +} + +type controlledExecutor struct { + turn chan *controlledTurn + starts atomic.Int32 + closes atomic.Int32 +} + +func newExecutor() *controlledExecutor { + return &controlledExecutor{turn: make(chan *controlledTurn, 1)} +} +func (e *controlledExecutor) Close(context.Context) error { e.closes.Add(1); return nil } +func (e *controlledExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + e.starts.Add(1) + turn := &controlledTurn{id: id, out: out, cancelling: make(chan struct{}), allowCancel: make(chan struct{}), settled: make(chan struct{})} + e.turn <- turn + return turn, nil +} + +type controlledTurn struct { + id string + out chan<- proto.Envelope + cancelling, allowCancel, settled chan struct{} + cancelOnce, finishOnce sync.Once +} + +func (turn *controlledTurn) Cancel(ctx context.Context) error { + turn.cancelOnce.Do(func() { close(turn.cancelling) }) + select { + case <-turn.allowCancel: + turn.finishOnce.Do(func() { close(turn.out); close(turn.settled) }) + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (turn *controlledTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "partial", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (turn *controlledTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-turn.settled: + return agent.TurnSettlement{Reason: "cancelled fixture"}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func TestWireContractCancellationWaitsForSettlement(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return executor, nil }) + prepared, ready := f.prepare(t) + if executor.starts.Load() != 0 { + t.Fatal("preparation submitted input") + } + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "run", Input: proto.TextInput("hello")}) + started := status(t, prepared, "started") + if started.RunID != "run" || started.ExecutorID != ready.ExecutorID { + t.Fatal("start lost execution identity") + } + var turn *controlledTurn + select { + case turn = <-executor.turn: + case <-time.After(3 * time.Second): + t.Fatal("no native turn") + } + var unblock sync.Once + release := func() { unblock.Do(func() { close(turn.allowCancel) }) } + t.Cleanup(release) + result := make(chan proto.InteractionDecisionAckPayload, 1) + ackErr := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + env, _ := proto.NewEnvelope(proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"}) + ack, err := f.core.SendAndWaitInteractionAck(ctx, env, "cancel") + result <- ack + ackErr <- err + }() + select { + case <-turn.cancelling: + case <-time.After(3 * time.Second): + t.Fatal("cancellation was not received") + } + select { + case <-result: + t.Fatal("receipt preceded native settlement") + default: + } + release() + select { + case ack := <-result: + if err := <-ackErr; err != nil || !ack.Applied || ack.Outcome == nil || ack.Outcome.Content != "partial" { + t.Fatalf("invalid cancellation receipt: %+v, %v", ack, err) + } + case <-time.After(3 * time.Second): + t.Fatal("missing settled cancellation receipt") + } + f.core.Unsubscribe("run") + for env := range run.Events { + if env.ID != "run" { + t.Fatal("settled cancellation output lost Run correlation") + } + } + if executor.starts.Load() != 1 { + t.Fatal("input was submitted more than once") + } +} + +func TestWireContractPreparationFailureCleansUpWithoutRunCompletion(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + return executor, errors.New("native setup failed") + }) + sub, err := f.core.SubscribePreparation("prepare") + if err != nil { + t.Fatal(err) + } + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "contract", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}}) + failed := status(t, sub, "failed") + if failed.ErrorCode != "preparation_failed" || executor.starts.Load() != 0 || executor.closes.Load() != 1 { + t.Fatalf("failed preparation retained resources or started input: %+v starts=%d closes=%d", failed, executor.starts.Load(), executor.closes.Load()) + } + if len(run.Events) != 0 { + t.Fatal("preparation failure manufactured a Run result") + } +} + +func TestWireContractDisconnectIsUnknownAndReconnectDoesNotReplay(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return executor, nil }) + prepared, ready := f.prepare(t) + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "run", Input: proto.TextInput("hello")}) + status(t, prepared, "started") + var turn *controlledTurn + select { + case turn = <-executor.turn: + case <-time.After(3 * time.Second): + t.Fatal("no native turn") + } + close(turn.allowCancel) + // Work has started. Losing observation cannot establish its result even + // when Runtime subsequently settles cleanup. + f.conn.Close() + select { + case <-f.core.Closed(): + case <-time.After(3 * time.Second): + t.Fatal("gateway stayed connected") + } + for env := range run.Events { + if env.Type == proto.TypeError || env.Type == proto.TypeDone { + t.Fatal("disconnect manufactured execution failure/completion") + } + } + if !errors.Is(run.Err(), gateway.ErrSessionClosed) { + t.Fatalf("disconnect outcome: %v", run.Err()) + } + conn, err := dial(t, f.endpoint, proto.Version) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + core, err := f.reg.WaitForDevice(t.Context(), "runtime", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer core.Close("test done") + if core == f.core || f.reg.LookupRun("run") != nil { + t.Fatal("reconnect inherited stale Run ownership") + } + select { + case env := <-conn.Recv(): + t.Fatalf("reconnect replayed work for old handle %s: %+v", ready.Handle, env) + case <-time.After(30 * time.Millisecond): + } + select { + case <-f.stopped: + if f.shutdownErr != nil || executor.closes.Load() != 1 { + t.Fatalf("disconnect cleanup: %v, closes=%d", f.shutdownErr, executor.closes.Load()) + } + case <-time.After(3 * time.Second): + t.Fatal("disconnect cleanup did not settle") + } + if executor.starts.Load() != 1 { + t.Fatal("reconnect replayed input") + } +} diff --git a/apps/parsar-daemon/internal/daemonize/fork.go b/apps/parsar-daemon/internal/daemonize/fork.go index 5e8380cb5..8803c7ee1 100644 --- a/apps/parsar-daemon/internal/daemonize/fork.go +++ b/apps/parsar-daemon/internal/daemonize/fork.go @@ -1,5 +1,5 @@ // Package daemonize gives `oac-daemon connect -b` a no-cgo way to -// detach from the controlling terminal on macOS + Linux. Strategy is +// detach from the controlling terminal on macOS, Linux and Windows. Strategy is // re-exec-the-binary rather than POSIX double-fork: the parent opens // connect.log + connect.pid, then starts a fresh copy of its own // argv with stdio redirected to the log file and a sentinel env var @@ -16,7 +16,7 @@ import ( "fmt" "os" "os/exec" - "syscall" + "strings" ) // BackgroundSentinelEnv is set by the parent on the child's @@ -38,8 +38,8 @@ type ReExecOptions struct { // /dev/null; stdout+stderr are appended to this file (0o600). LogPath string - // PIDPath is the absolute pidfile path. The parent writes the - // child's PID here before returning; existing files are replaced + // PIDPath is the absolute process-record path. The parent writes the + // child identity here before returning; existing files are replaced // atomically. PIDPath string @@ -63,7 +63,7 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { return 0, errors.New("daemonize.Spawn: LogPath and PIDPath required") } - logFile, err := os.OpenFile(opts.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + logFile, err := openPrivateLog(opts.LogPath) if err != nil { return 0, fmt.Errorf("daemonize: open log %s: %w", opts.LogPath, err) } @@ -96,29 +96,42 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { cmd.Stdin = devNull cmd.Stdout = logFile cmd.Stderr = logFile - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // new session → no controlling tty + cleanup, ready, err := configureBackground(cmd) + if err != nil { + return 0, err } + defer cleanup() if err := cmd.Start(); err != nil { return 0, fmt.Errorf("daemonize: start child: %w", err) } pid := cmd.Process.Pid - - // Release rather than Wait — don't take zombie reaping duties. - // Init takes over once the parent exits. - if err := cmd.Process.Release(); err != nil { - // Non-fatal; child is already running. Log to the caller's - // chain so it shows up in stderr but doesn't fail the spawn. - fmt.Fprintf(os.Stderr, "daemonize: warning: release child: %v\n", err) + if err := ready(); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + identity, err := identifyProcess(pid) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + for _, entry := range cmd.Env { + if strings.HasPrefix(entry, stopEventEnv+"=") { + identity.StopEvent = strings.TrimPrefix(entry, stopEventEnv+"=") + } } - if err := WritePIDFile(opts.PIDPath, pid); err != nil { - // Kill the child so we don't leave a daemon the user can't - // `stop` without ps-grepping. - _ = syscall.Kill(pid, syscall.SIGTERM) - return 0, fmt.Errorf("daemonize: write pidfile (child killed): %w", err) + if err := writeIdentity(opts.PIDPath, identity); err != nil { + // The parent still owns this exact process handle until publication succeeds. + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, fmt.Errorf("daemonize: write process record: %w", err) + } + if err := cmd.Process.Release(); err != nil { + fmt.Fprintf(os.Stderr, "daemonize: release child handle: %v\n", err) } return pid, nil diff --git a/apps/parsar-daemon/internal/daemonize/fork_test.go b/apps/parsar-daemon/internal/daemonize/fork_test.go index 278c90a40..7ad546605 100644 --- a/apps/parsar-daemon/internal/daemonize/fork_test.go +++ b/apps/parsar-daemon/internal/daemonize/fork_test.go @@ -4,13 +4,12 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "time" ) func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") @@ -29,7 +28,11 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Fatalf("Spawn: %v", err) } defer func() { - _ = syscall.Kill(pid, syscall.SIGTERM) + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() if pid <= 0 { @@ -63,9 +66,9 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Errorf("log missing stderr marker; got %q", string(logBody)) } - // SignalAndWait should now drop the child cleanly. - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) + // StopPIDFile should now drop the child cleanly. + if err := StopPIDFile(pidPath, 2*time.Second); err != nil { + t.Fatalf("StopPIDFile: %v", err) } } diff --git a/apps/parsar-daemon/internal/daemonize/fork_unix.go b/apps/parsar-daemon/internal/daemonize/fork_unix.go new file mode 100644 index 000000000..d729a25be --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_unix.go @@ -0,0 +1,13 @@ +//go:build unix + +package daemonize + +import ( + "os/exec" + "syscall" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return func() {}, func() error { return nil }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/parsar-daemon/internal/daemonize/fork_windows.go new file mode 100644 index 000000000..727d707a1 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package daemonize + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os/exec" + "strings" + "syscall" + + "golang.org/x/sys/windows" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, nil, err + } + name := `Local\OpenAgentCore-daemon-` + hex.EncodeToString(nonce[:]) + stopName, _ := windows.UTF16PtrFromString(name) + event, err := windows.CreateEvent(nil, 1, 0, stopName) + if err != nil { + return nil, nil, err + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.CreateEvent(nil, 1, 0, readyName) + if err != nil { + windows.CloseHandle(event) + return nil, nil, err + } + clean := func() { windows.CloseHandle(event); windows.CloseHandle(ready) } + filtered := cmd.Env[:0] + for _, entry := range cmd.Env { + if !strings.HasPrefix(entry, stopEventEnv+"=") { + filtered = append(filtered, entry) + } + } + cmd.Env = append(filtered, stopEventEnv+"="+name) + // Detach from the console without escaping a CI or service host Job. + // Such a host can still end the daemon when its own Job is closed. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP} + return clean, func() error { + status, err := windows.WaitForSingleObject(ready, 60000) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: background child did not establish its stop endpoint") + } + return nil + }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/helpers_test.go b/apps/parsar-daemon/internal/daemonize/helpers_test.go index 68bf9ea7c..6f32354cc 100644 --- a/apps/parsar-daemon/internal/daemonize/helpers_test.go +++ b/apps/parsar-daemon/internal/daemonize/helpers_test.go @@ -1,109 +1,47 @@ package daemonize import ( + "context" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "os/exec" - "os/signal" - "strconv" - "syscall" + "path/filepath" "testing" "time" ) -// spawnTestChildEnv flips the test binary into "child" mode for fork -// tests: TestMain writes a marker, blocks until SIGTERM/SIGINT, exits. -// Lets Spawn re-exec the test binary as the child. const spawnTestChildEnv = "OAC_TEST_DAEMON_SPAWN_CHILD" func TestMain(m *testing.M) { - if os.Getenv(spawnTestChildEnv) == "1" { - runSpawnTestChild() + if mode := os.Getenv(spawnTestChildEnv); mode != "" { + _, firstStop := NotifyContext(context.Background()) + firstStop() + ctx, stop := NotifyContext(context.Background()) + defer stop() + fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") + fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") + if mode == "ignore" { + time.Sleep(15 * time.Second) + return + } + select { + case <-ctx.Done(): + case <-time.After(15 * time.Second): + } return } os.Exit(m.Run()) } -func runSpawnTestChild() { - fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") - fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") - - // Honour SIGTERM so the parent test can clean us up. Fallback - // deadline so a stranded child doesn't survive a crashing test. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - select { - case <-sigCh: - case <-time.After(15 * time.Second): - } - os.Exit(0) -} - -// startSleepingChild launches a child that sleeps for dur and exits. -// dur=0 → `sh -c true` for testing "signal a process that's already -// gone" paths. sh because we need a real kernel PID with /proc entry. -func startSleepingChild(t *testing.T, dur time.Duration) *exec.Cmd { - t.Helper() - var cmd *exec.Cmd - if dur <= 0 { - cmd = exec.Command("sh", "-c", "true") - } else { - // Convert to whole seconds when possible, sub-second via sleep - // arg with decimal (Linux/macOS sh both accept "sleep 0.05"). - secs := dur.Seconds() - cmd = exec.Command("sh", "-c", "sleep "+strconv.FormatFloat(secs, 'f', 3, 64)) - } - if err := cmd.Start(); err != nil { - t.Fatalf("startSleepingChild: %v", err) - } - return cmd -} - -// startTrapChild launches a child that ignores SIGTERM, forcing the -// SIGKILL escalation path. Waits for READY on stdout so a fast caller -// doesn't race the trap install. -func startTrapChild(t *testing.T) *exec.Cmd { +func privateTempDir(t *testing.T) string { t.Helper() - cmd := exec.Command("sh", "-c", "trap '' TERM; echo READY; sleep 30") - stdout, err := cmd.StdoutPipe() + parent, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { - t.Fatalf("startTrapChild StdoutPipe: %v", err) + t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatalf("startTrapChild Start: %v", err) - } - // Read up to READY with a hard deadline so a broken shell can't - // hang the test. - readyCh := make(chan error, 1) - go func() { - buf := make([]byte, 16) - var got []byte - for { - n, err := stdout.Read(buf) - if n > 0 { - got = append(got, buf[:n]...) - if len(got) >= 5 && string(got[:5]) == "READY" { - readyCh <- nil - return - } - } - if err != nil { - readyCh <- err - return - } - } - }() - select { - case err := <-readyCh: - if err != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild waiting READY: %v", err) - } - case <-time.After(2 * time.Second): - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild: timed out waiting for READY") + dir := filepath.Join(parent, "private") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatal(err) } - return cmd + return dir } diff --git a/apps/parsar-daemon/internal/daemonize/logfile.go b/apps/parsar-daemon/internal/daemonize/logfile.go index 65e25c061..c83de6f6a 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile.go +++ b/apps/parsar-daemon/internal/daemonize/logfile.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "io" "os" "path/filepath" @@ -137,11 +138,11 @@ func lastLinesOffset(f *os.File, n int) (int64, error) { // instead of "no such file". func EnsureLogFile(path string) error { if dir := filepath.Dir(path); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + f, err := openPrivateLog(path) if err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } @@ -152,7 +153,7 @@ func EnsureLogFile(path string) error { // trailing newline if missing. Returns errors despite the name — // kept short because it's used in startup hot paths. func MustWriteLine(path string, line string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + f, err := openPrivateLog(path) if err != nil { return err } @@ -168,3 +169,12 @@ func MustWriteLine(path string, line string) error { } return bw.Flush() } + +func openPrivateLog(path string) (*os.File, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer root.Close() + return runtimefs.OpenPrivate(root, filepath.Base(path), os.O_APPEND|os.O_CREATE|os.O_WRONLY) +} diff --git a/apps/parsar-daemon/internal/daemonize/logfile_test.go b/apps/parsar-daemon/internal/daemonize/logfile_test.go index 3b5f5ce0b..a13bdca48 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/logfile_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -11,7 +12,7 @@ import ( ) func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") var b strings.Builder for i := 1; i <= 10; i++ { @@ -35,7 +36,7 @@ func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { } func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -51,7 +52,7 @@ func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { } func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -66,7 +67,7 @@ func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { } func TestTailFollowPicksUpAppendedBytes(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("initial\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -128,14 +129,14 @@ func TestTailFollowPicksUpAppendedBytes(t *testing.T) { func TestTailErrorsOnMissingFile(t *testing.T) { var buf bytes.Buffer - err := Tail(filepath.Join(t.TempDir(), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) + err := Tail(filepath.Join(privateTempDir(t), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) if err == nil { t.Fatalf("Tail on missing file succeeded") } } func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile: %v", err) @@ -147,13 +148,13 @@ func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { if info.Size() != 0 { t.Errorf("size = %d, want 0", info.Size()) } - if info.Mode().Perm() != 0o600 { + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Errorf("mode = %o, want 0600", info.Mode().Perm()) } } func TestEnsureLogFileIdempotentOnExisting(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "exists.log") if err := os.WriteFile(path, []byte("preexisting\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -171,7 +172,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { // Regression: first-ever `oac-daemon connect -b` on a host without // ~/.oac/daemon// used to fail with ENOENT — // O_CREATE only creates the file leaf. - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "missing", "deeper", "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile on missing parent: %v", err) @@ -182,7 +183,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { } func TestMustWriteLineAppendsTrailingNewline(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "ml.log") if err := MustWriteLine(path, "no-newline"); err != nil { t.Fatalf("MustWriteLine: %v", err) diff --git a/apps/parsar-daemon/internal/daemonize/notify_unix.go b/apps/parsar-daemon/internal/daemonize/notify_unix.go new file mode 100644 index 000000000..0e63ee4f6 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package daemonize + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) +} diff --git a/apps/parsar-daemon/internal/daemonize/notify_windows.go b/apps/parsar-daemon/internal/daemonize/notify_windows.go new file mode 100644 index 000000000..cff5d173e --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package daemonize + +import ( + "context" + "os" + "os/signal" + "sync" + + "golang.org/x/sys/windows" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +// This process-lifetime handle survives transitions from serving to parked. +// Windows releases it on process exit, including abrupt termination. +var backgroundStop struct { + once sync.Once + handle windows.Handle + err error +} + +func openBackgroundStop(name string) (windows.Handle, error) { + backgroundStop.once.Do(func() { + var stopName *uint16 + stopName, backgroundStop.err = windows.UTF16PtrFromString(name) + if backgroundStop.err != nil { + return + } + backgroundStop.handle, backgroundStop.err = windows.OpenEvent(windows.SYNCHRONIZE, false, stopName) + if backgroundStop.err != nil { + return + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, readyName) + if err != nil { + backgroundStop.err = err + return + } + backgroundStop.err = windows.SetEvent(ready) + windows.CloseHandle(ready) + }) + return backgroundStop.handle, backgroundStop.err +} + +// NotifyContext serves both foreground Ctrl-C and an owned background stop event. +// The ready event is signalled only after the child holds its own stop handle. +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := signal.NotifyContext(parent, os.Interrupt) + name := os.Getenv(stopEventEnv) + if name == "" { + return ctx, cancel + } + event, err := openBackgroundStop(name) + if err != nil { + cancel() + return ctx, cancel + } + go func() { + for ctx.Err() == nil { + status, err := windows.WaitForSingleObject(event, 100) + if err != nil || status == windows.WAIT_OBJECT_0 { + cancel() + return + } + } + }() + return ctx, cancel +} diff --git a/apps/parsar-daemon/internal/daemonize/pidfile.go b/apps/parsar-daemon/internal/daemonize/pidfile.go index b9d9665b5..0a5f548e3 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile.go @@ -1,123 +1,102 @@ package daemonize import ( + "encoding/json" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "strconv" - "strings" - "syscall" + "path/filepath" "time" ) -// ErrStaleOrCorrupt is returned by ReadPIDFile when the file exists -// but its contents don't look like a live PID. Wraps the underlying -// parse error or syscall.ESRCH for errors.Is. -var ErrStaleOrCorrupt = errors.New("daemonize: pidfile stale or corrupt") - -// ErrNotRunning is returned by IsAlive when the PID has no process. +var ErrStaleOrCorrupt = errors.New("daemonize: unsupported, stale or corrupt process record") var ErrNotRunning = errors.New("daemonize: process not running") -// WritePIDFile writes pid to path atomically. The temp file lives in -// the same directory so the rename is on the same filesystem -// (required by os.Rename for atomicity). 0o600 matches the profile -// dir's privacy. +type processIdentity struct { + PID int `json:"pid"` + Start string `json:"start"` + StopEvent string `json:"stop_event,omitempty"` +} + func WritePIDFile(path string, pid int) error { - if path == "" { - return errors.New("daemonize.WritePIDFile: empty path") + identity, err := identifyProcess(pid) + if err != nil { + return err } - if pid <= 0 { - return fmt.Errorf("daemonize.WritePIDFile: invalid pid %d", pid) + identity.StopEvent = os.Getenv(stopEventEnv) + return writeIdentity(path, identity) +} + +func writeIdentity(path string, identity processIdentity) error { + if path == "" { + return errors.New("daemonize: process record path required") } - tmp := path + ".tmp" - body := []byte(strconv.Itoa(pid) + "\n") - if err := os.WriteFile(tmp, body, 0o600); err != nil { - return fmt.Errorf("daemonize: write tmp pidfile: %w", err) + raw, err := json.Marshal(identity) + if err != nil { + return err } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("daemonize: rename pidfile: %w", err) + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err } - return nil + defer root.Close() + return runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) } -// ReadPIDFile reads, parses, and liveness-checks the pidfile. -// Missing file returns os.ErrNotExist verbatim; a file whose pid has -// no live process returns ErrStaleOrCorrupt wrapping ErrNotRunning. -func ReadPIDFile(path string) (int, error) { - raw, err := os.ReadFile(path) +func readIdentity(path string) (processIdentity, error) { + raw, err := runtimefs.ReadPrivatePath(path, 4096) if err != nil { - return 0, err + return processIdentity{}, err + } + var value processIdentity + if json.Unmarshal(raw, &value) != nil || value.PID <= 0 || value.Start == "" { + return processIdentity{}, ErrStaleOrCorrupt } - pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw))) - if parseErr != nil || pid <= 0 { - return 0, fmt.Errorf("%w: %q", ErrStaleOrCorrupt, strings.TrimSpace(string(raw))) + actual, err := identifyProcess(value.PID) + if err != nil { + if errors.Is(err, ErrNotRunning) { + return processIdentity{}, fmt.Errorf("%w: %w", ErrStaleOrCorrupt, err) + } + return processIdentity{}, err } - if err := IsAlive(pid); err != nil { - return pid, fmt.Errorf("%w: pid=%d: %w", ErrStaleOrCorrupt, pid, err) + if actual.Start != value.Start { + return processIdentity{}, ErrStaleOrCorrupt } - return pid, nil + return value, nil +} + +func ReadPIDFile(path string) (int, error) { + identity, err := readIdentity(path) + return identity.PID, err } -// IsAlive returns nil if pid corresponds to a process this user can -// signal. Wraps ErrNotRunning when the process is gone; other errors -// (e.g. EPERM) returned verbatim. -func IsAlive(pid int) error { - if pid <= 0 { - return fmt.Errorf("daemonize.IsAlive: invalid pid %d", pid) +func IsAlive(pid int) error { _, err := identifyProcess(pid); return err } + +// StopPIDFile never removes ownership before the exact process has exited. +// A cleanup timeout leaves the record available for observation and a later stop. +func StopPIDFile(path string, timeout time.Duration) error { + identity, err := readIdentity(path) + if err != nil { + return err } - // signal 0 is the POSIX "does this process exist?" probe: - // nil → exists, signalable - // ESRCH → no such process - // EPERM → exists but owned by another user - if err := syscall.Kill(pid, syscall.Signal(0)); err != nil { - if errors.Is(err, syscall.ESRCH) { - return fmt.Errorf("%w (pid=%d)", ErrNotRunning, pid) - } + if err = stopProcess(identity, timeout); err != nil { return err } - return nil + raw, err := runtimefs.ReadPrivatePath(path, 4096) + if err != nil { + return err + } + var current processIdentity + if json.Unmarshal(raw, ¤t) != nil || current != identity { + return errors.New("daemonize: process ownership changed during stop") + } + return RemovePIDFile(path) } -// RemovePIDFile deletes the pidfile. Missing files aren't an error so -// `oac-daemon stop` is idempotent. func RemovePIDFile(path string) error { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("daemonize: remove pidfile: %w", err) - } - return nil -} - -// SignalAndWait SIGTERMs pid, polls for exit on 100ms cadence up to -// timeout, then SIGKILLs if still alive. Caller is responsible for -// removing the pidfile after success. -func SignalAndWait(pid int, timeout time.Duration) error { - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGTERM pid=%d: %w", pid, err) - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if err := IsAlive(pid); err != nil { - if errors.Is(err, ErrNotRunning) { - return nil - } - return err - } - time.Sleep(100 * time.Millisecond) - } - - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGKILL pid=%d: %w", pid, err) + return err } - // Grace window for the kernel to reap before any subsequent - // IsAlive call. - time.Sleep(50 * time.Millisecond) return nil } diff --git a/apps/parsar-daemon/internal/daemonize/pidfile_test.go b/apps/parsar-daemon/internal/daemonize/pidfile_test.go index 1a8906b58..f390a7837 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile_test.go @@ -1,168 +1,98 @@ package daemonize import ( + "encoding/json" "errors" "os" "path/filepath" - "syscall" "testing" "time" ) -func TestWriteAndReadPIDFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "connect.pid") - pid := os.Getpid() - - if err := WritePIDFile(path, pid); err != nil { - t.Fatalf("WritePIDFile: %v", err) - } - - got, err := ReadPIDFile(path) - if err != nil { - t.Fatalf("ReadPIDFile: %v", err) +func TestProcessRecordIdentity(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if err := WritePIDFile(path, os.Getpid()); err != nil { + t.Fatal(err) } - if got != pid { - t.Fatalf("ReadPIDFile = %d, want %d", got, pid) + pid, err := ReadPIDFile(path) + if err != nil || pid != os.Getpid() { + t.Fatal(pid, err) } - - info, err := os.Stat(path) + raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("stat: %v", err) + t.Fatal(err) } - if info.Mode().Perm() != 0o600 { - t.Errorf("pidfile mode = %o, want 0600", info.Mode().Perm()) + var identity processIdentity + if json.Unmarshal(raw, &identity) != nil || identity.Start == "" { + t.Fatal("missing process start identity") } -} - -func TestReadPIDFileMissingReturnsErrNotExist(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "nope.pid") - _, err := ReadPIDFile(path) - if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("err = %v, want os.ErrNotExist", err) + identity.Start += "different" + raw, _ = json.Marshal(identity) + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) } -} - -func TestReadPIDFileCorruptReturnsStaleOrCorrupt(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.pid") - if err := os.WriteFile(path, []byte("not a number\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) + if err = StopPIDFile(path, time.Second); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal("stale identity was accepted", err) } - _, err := ReadPIDFile(path) - if !errors.Is(err, ErrStaleOrCorrupt) { - t.Fatalf("err = %v, want ErrStaleOrCorrupt", err) + if err = IsAlive(os.Getpid()); err != nil { + t.Fatal("unrelated process was affected", err) } -} - -func TestReadPIDFileStaleReturnsStaleOrCorrupt(t *testing.T) { - // Pick a PID almost certainly dead. WritePIDFile rejects 0 so we - // write manually. - dir := t.TempDir() - path := filepath.Join(dir, "stale.pid") - if err := os.WriteFile(path, []byte("99999999\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { - // Skip if 99999999 happens to exist. - t.Logf("ReadPIDFile = %v (PID 99999999 may exist on this host); skipping", err) - t.SkipNow() + if _, err = os.Stat(path); err != nil { + t.Fatal("unconfirmed record removed", err) } } -func TestIsAliveTrueForOurselves(t *testing.T) { - if err := IsAlive(os.Getpid()); err != nil { - t.Fatalf("IsAlive(self) = %v, want nil", err) +func TestProcessRecordRejectsOldAndMalformed(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if _, err := ReadPIDFile(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } -} - -func TestIsAliveFalseForDeadPID(t *testing.T) { - err := IsAlive(99999999) - if err == nil { - t.Skip("PID 99999999 unexpectedly exists; skipping") - } - if !errors.Is(err, ErrNotRunning) { - t.Fatalf("err = %v, want ErrNotRunning", err) - } -} - -func TestRemovePIDFileIdempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "x.pid") - if err := WritePIDFile(path, os.Getpid()); err != nil { - t.Fatalf("write: %v", err) + for _, raw := range []string{"123\n", "{}", "not-json"} { + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal(raw, err) + } } if err := RemovePIDFile(path); err != nil { - t.Fatalf("first remove: %v", err) + t.Fatal(err) } - // Second remove on missing file should be a no-op. if err := RemovePIDFile(path); err != nil { - t.Fatalf("second remove: %v", err) + t.Fatal(err) } } -func TestSignalAndWaitKillsChild(t *testing.T) { - // Launch a sleeping subshell so we have a real PID to signal. - cmd := startSleepingChild(t, 30*time.Second) - pid := cmd.Process.Pid - defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - }() - - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - // In production, init reaps the child so IsAlive returns - // ErrNotRunning. In tests we're the parent so the child becomes - // a zombie — Wait() reaps it. - if _, err := cmd.Process.Wait(); err != nil { - t.Fatalf("Wait after SignalAndWait: %v", err) - } - if err := IsAlive(pid); err == nil { - t.Fatalf("child still alive after SignalAndWait+Wait") - } else if !errors.Is(err, ErrNotRunning) { - t.Fatalf("IsAlive err = %v, want ErrNotRunning", err) +func TestStopTimeoutRetainsProcessRecord(t *testing.T) { + dir := privateTempDir(t) + path := filepath.Join(dir, "connect.pid") + pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path, ExtraEnv: []string{spawnTestChildEnv + "=ignore"}}) + if err != nil { + t.Fatal(err) } -} - -func TestSignalAndWaitEscalatesToSIGKILL(t *testing.T) { - // Child traps SIGTERM and refuses to exit. 300ms timeout so the - // escalation path fires quickly. - cmd := startTrapChild(t) - pid := cmd.Process.Pid defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() - - start := time.Now() - if err := SignalAndWait(pid, 300*time.Millisecond); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - if d := time.Since(start); d < 250*time.Millisecond { - t.Errorf("escalation happened too early (took %s); SIGTERM grace period not honoured", d) - } - // Reap so the kernel reuses the PID. ProcessState confirms SIGKILL. - state, err := cmd.Process.Wait() - if err != nil { - t.Fatalf("Wait after escalation: %v", err) - } - ws, ok := state.Sys().(syscall.WaitStatus) - if !ok || !ws.Signaled() || ws.Signal() != syscall.SIGKILL { - t.Fatalf("child exit = %v, want killed by SIGKILL", state) - } -} - -func TestSignalAndWaitNoopOnAlreadyDead(t *testing.T) { - cmd := startSleepingChild(t, 0) - pid := cmd.Process.Pid - // Wait for child to actually be gone before signalling so we - // test the ESRCH branch rather than a race. - _, _ = cmd.Process.Wait() - - if err := SignalAndWait(pid, time.Second); err != nil { - t.Fatalf("SignalAndWait on dead pid: %v, want nil", err) + // Unix has no readiness event; wait until the child installs its signal handler. + deadline := time.Now().Add(3 * time.Second) + for { + raw, _ := os.ReadFile(filepath.Join(dir, "log")) + if len(raw) > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("child not ready") + } + time.Sleep(10 * time.Millisecond) + } + if err = StopPIDFile(path, 100*time.Millisecond); err == nil { + t.Fatal("unconfirmed stop succeeded") + } + if got, err := ReadPIDFile(path); err != nil || got != pid { + t.Fatal("timeout lost ownership", got, err) } } diff --git a/apps/parsar-daemon/internal/daemonize/process_darwin.go b/apps/parsar-daemon/internal/daemonize/process_darwin.go new file mode 100644 index 000000000..5945827f4 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_darwin.go @@ -0,0 +1,52 @@ +//go:build darwin + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(err, unix.ESRCH) || (err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5)) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) { + return processIdentity{}, ErrNotRunning + } + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fmt.Sprintf("%d:%d", info.Proc.P_starttime.Sec, info.Proc.P_starttime.Usec)}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + // Darwin has no pidfd; identity is checked immediately before signalling. + // PID reuse between this check and kill remains an OS-level race. + if err = unix.Kill(identity.PID, unix.SIGTERM); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + current, err = identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) || (err == nil && current.Start != identity.Start) { + return nil + } + if err != nil { + return err + } + time.Sleep(20 * time.Millisecond) + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_linux.go b/apps/parsar-daemon/internal/daemonize/process_linux.go new file mode 100644 index 000000000..685cd8e4c --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package daemonize + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if errors.Is(err, os.ErrNotExist) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + end := strings.LastIndexByte(string(raw), ')') + if end < 0 { + return processIdentity{}, errors.New("daemonize: invalid process identity") + } + fields := strings.Fields(string(raw[end+1:])) + if len(fields) < 20 || fields[0] == "Z" || fields[0] == "X" { + return processIdentity{}, ErrNotRunning + } + if _, err := strconv.ParseUint(fields[19], 10, 64); err != nil { + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fields[19]}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := unix.PidfdOpen(identity.PID, 0) + if errors.Is(err, unix.ESRCH) { + return nil + } + if err != nil { + return err + } + defer unix.Close(handle) + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + if err = unix.PidfdSendSignal(handle, unix.SIGTERM, nil, 0); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + n, err := unix.Poll([]unix.PollFd{{Fd: int32(handle), Events: unix.POLLIN}}, 20) + if err != nil && !errors.Is(err, unix.EINTR) { + return err + } + if n > 0 { + return nil + } + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_windows.go b/apps/parsar-daemon/internal/daemonize/process_windows.go new file mode 100644 index 000000000..5a2a052bf --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_windows.go @@ -0,0 +1,81 @@ +//go:build windows + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/windows" +) + +func processStart(handle windows.Handle) (string, error) { + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil { + return "", err + } + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return "", err + } + if status == windows.WAIT_OBJECT_0 { + return "", ErrNotRunning + } + return fmt.Sprintf("%d:%d", creation.HighDateTime, creation.LowDateTime), nil +} +func identifyProcess(pid int) (processIdentity, error) { + if pid <= 0 { + return processIdentity{}, ErrNotRunning + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + return processIdentity{PID: pid, Start: start}, err +} +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(identity.PID)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + if err != nil { + return err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || start != identity.Start { + return ErrStaleOrCorrupt + } + if identity.StopEvent == "" { + return errors.New("daemonize: process has no owned stop endpoint") + } + name, err := windows.UTF16PtrFromString(identity.StopEvent) + if err != nil { + return err + } + event, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, name) + if err != nil { + return err + } + defer windows.CloseHandle(event) + if err = windows.SetEvent(event); err != nil { + return err + } + status, err := windows.WaitForSingleObject(handle, uint32(max(timeout.Milliseconds(), 1))) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: cleanup unconfirmed; process record retained") + } + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/executor.go b/apps/parsar-daemon/internal/dispatch/executor.go new file mode 100644 index 000000000..afe959a66 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor.go @@ -0,0 +1,376 @@ +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 { + 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 := r.availableCapabilities(req.AgentKind) + factory, err := r.registry.ResolveExecutor(req.AgentKind) + if err != nil || !caps.Preparation { + 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 { + 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{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{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/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go new file mode 100644 index 000000000..8ea3b6ee2 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -0,0 +1,249 @@ +package dispatch_test + +import ( + "context" + "errors" + "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" +) + +type receiptCancelSender struct { + *recSender + entered, release chan struct{} + once sync.Once +} + +func (s *receiptCancelSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypePromptSteerAck { + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if ack.InputID == "pending" && !ack.Written { + s.once.Do(func() { close(s.entered) }) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return s.recSender.Send(ctx, env) +} + +type receiptCancelExecutor struct { + turn chan *receiptCancelTurn + current *receiptCancelTurn + cancelFails, closeFailsFirst bool + closeEntered, closeRelease chan struct{} + closes atomic.Int32 + closeOnce sync.Once +} + +func (e *receiptCancelExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn := &receiptCancelTurn{id: id, out: out, owner: e, written: make(chan struct{}), cancelled: make(chan struct{}), closed: make(chan struct{}), settled: make(chan struct{}), awaiting: make(chan struct{})} + e.current = turn + e.turn <- turn + return turn, nil +} +func (e *receiptCancelExecutor) Close(context.Context) error { + attempt := e.closes.Add(1) + if e.closeFailsFirst && attempt == 1 { + return errors.New("native close unconfirmed") + } + e.closeOnce.Do(func() { close(e.closeEntered) }) + <-e.closeRelease + if e.current != nil { + e.current.closeOnce.Do(func() { close(e.current.closed) }) + e.current.finish() + } + return nil +} + +type receiptCancelTurn struct { + id string + out chan<- proto.Envelope + owner *receiptCancelExecutor + written, cancelled, closed, settled, awaiting chan struct{} + cancelOnce, closeOnce, finishOnce, terminalOnce, awaitOnce sync.Once + cancels atomic.Int32 +} + +func (t *receiptCancelTurn) terminal() { + t.terminalOnce.Do(func() { event, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()); t.out <- event }) +} +func (t *receiptCancelTurn) finish() { + t.finishOnce.Do(func() { t.terminal(); close(t.out); close(t.settled) }) +} +func (t *receiptCancelTurn) Cancel(context.Context) error { + t.cancels.Add(1) + t.cancelOnce.Do(func() { close(t.cancelled) }) + if t.owner.cancelFails { + return errors.New("native cancellation unconfirmed") + } + t.finish() + return nil +} +func (t *receiptCancelTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + t.awaitOnce.Do(func() { close(t.awaiting) }) + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: !t.owner.cancelFails}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} +func (t *receiptCancelTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "observed", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (t *receiptCancelTurn) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return t.SteerWithReceipt(ctx, input, nil) +} +func (t *receiptCancelTurn) SteerWithReceipt(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { + written() + close(t.written) + native := t.cancelled + if t.owner.cancelFails { + native = t.closed + } + select { + case <-native: + case <-ctx.Done(): + return ctx.Err() + } + return errors.New("native input consumption is unknown") +} + +func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) { + for _, mode := range []string{"cancel", "done_then_cancel", "cancel_failure", "close_failure_retry"} { + t.Run(mode, func(t *testing.T) { + sender := &receiptCancelSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + owner := &receiptCancelExecutor{turn: make(chan *receiptCancelTurn, 2), cancelFails: mode == "cancel_failure" || mode == "close_failure_retry", closeFailsFirst: mode == "close_failure_retry", closeEntered: make(chan struct{}), closeRelease: make(chan struct{})} + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("legacy factory forbidden") + }) + reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + var receiptOnce, closeOnce sync.Once + releaseReceipt := func() { receiptOnce.Do(func() { close(sender.release) }) } + releaseClose := func() { closeOnce.Do(func() { close(owner.closeRelease) }) } + t.Cleanup(func() { + releaseReceipt() + releaseClose() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + wait := func(ch <-chan struct{}, reason string) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal(reason) + } + } + p := executorAdmission(t, r, sender.recSender, "first", executorRequest()) + startExecutorTurn(t, r, sender.recSender, "first", "run", p) + turn := <-owner.turn + input := proto.PromptSteerPayload{InputID: "pending", Input: proto.TextInput("second input"), DurableReceipt: true} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { + t.Fatal(err) + } + wait(turn.written, "input did not reach native write") + if mode == "done_then_cancel" { + turn.terminal() + wait(turn.awaiting, "natural Done did not begin settlement") + } + cancel := func(id string) { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: id})); err != nil { + t.Fatal(err) + } + } + cancel("cancel") + wait(turn.cancelled, "receipt barrier prevented native cancellation") + acknowledgements := 0 + if mode == "close_failure_retry" { + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed Close acknowledgement") + ack := cancellationAcks(sender.recSender)[0] + if ack.Applied || ack.Outcome != nil || r.ActiveRuns() != 1 { + t.Fatal("failed Close released ownership or fabricated cancellation") + } + acknowledgements = 1 + cancel("retry") + } + if owner.cancelFails { + wait(owner.closeEntered, "failed cancellation did not reach resource cleanup") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cleanup was acknowledged before resource close") + } + releaseClose() + } + wait(sender.entered, "native cancellation did not unblock the written input") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cancellation passed an uncommitted input receipt") + } + for _, env := range sender.snapshot() { + if env.Type == proto.TypeDone { + t.Fatal("Done preceded the admitted input receipt") + } + } + releaseReceipt() + waitFor(t, func() bool { + return len(cancellationAcks(sender.recSender)) == acknowledgements+1 && r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") + }, "joined cancellation") + frames := sender.snapshot() + inputAt, doneAt, cancelAt := -1, -1, -1 + for i, env := range frames { + switch env.Type { + case proto.TypePromptSteerAck: + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if !ack.Written { + if ack.Accepted || ack.ErrorCode != "outcome_unknown" { + t.Fatalf("unknown input fabricated receipt: %+v", ack) + } + inputAt = i + } + case proto.TypeDone: + doneAt = i + case proto.TypeInteractionDecisionAck: + cancelAt = i + } + } + if inputAt < 0 || doneAt < inputAt || cancelAt < inputAt || !owner.cancelFails && cancelAt < doneAt { + t.Fatalf("receipt/Done/cancel order=%d/%d/%d", inputAt, doneAt, cancelAt) + } + ack := cancellationAcks(sender.recSender)[acknowledgements] + if ack.Applied == owner.cancelFails || (ack.Outcome != nil) == owner.cancelFails { + t.Fatalf("cancellation evidence changed during cleanup: %+v", ack) + } + if !owner.cancelFails { + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.RequireExistingNativeSession = true + next := executorAdmission(t, r, sender.recSender, "next", req) + if next.ExecutorID != p.ExecutorID || !next.Reused { + t.Fatal("healthy cancellation replaced owner") + } + startExecutorTurn(t, r, sender.recSender, "next", "next-run", next) + successor := <-owner.turn + cancel("late-old-run") + if successor.cancels.Load() != 0 || owner.closes.Load() != 0 { + t.Fatal("old cancellation reached successor") + } + successor.finish() + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go b/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go new file mode 100644 index 000000000..730698ef6 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go @@ -0,0 +1,228 @@ +package dispatch + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type terminalHandoffSender struct { + frames chan proto.Envelope + visible chan struct{} + release chan struct{} + failure error +} + +func (s *terminalHandoffSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.frames <- env: + case <-ctx.Done(): + return ctx.Err() + } + if env.Type == proto.TypeDone && env.ID == "first" { + close(s.visible) + select { + case <-s.release: + return s.failure + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +type terminalHandoffExecutor struct { + turns chan *terminalHandoffTurn + closes atomic.Int32 +} + +func (e *terminalHandoffExecutor) Close(context.Context) error { e.closes.Add(1); return nil } +func (e *terminalHandoffExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn := &terminalHandoffTurn{id: id, out: out, settled: make(chan struct{})} + e.turns <- turn + return turn, nil +} + +type terminalHandoffTurn struct { + id string + out chan<- proto.Envelope + settled chan struct{} + once sync.Once +} + +func (t *terminalHandoffTurn) finish() { + t.once.Do(func() { + env, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()) + t.out <- env + close(t.out) + close(t.settled) + }) +} +func (t *terminalHandoffTurn) Cancel(context.Context) error { t.finish(); return nil } +func (t *terminalHandoffTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native"}} +} +func (t *terminalHandoffTurn) 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 TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { + for _, fail := range []bool{false, true} { + name := "delivered" + if fail { + name = "late_delivery_error" + } + t.Run(name, func(t *testing.T) { + sender := &terminalHandoffSender{frames: make(chan proto.Envelope, 32), visible: make(chan struct{}), release: make(chan struct{})} + if fail { + sender.failure = errors.New("old terminal delivery failed") + } + owner := &terminalHandoffExecutor{turns: make(chan *terminalHandoffTurn, 3)} + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("legacy path forbidden") + }) + var creates atomic.Int32 + registry.RegisterExecutor("handoff", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + creates.Add(1) + return owner, nil + }) + r, err := New(Config{Registry: registry, Sender: sender, IdleTimeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(sender.release) }) } + t.Cleanup(func() { + unblock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + handle := func(kind, id string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + status := func(id, state string) proto.PreparationStatusPayload { + t.Helper() + deadline := time.After(time.Second) + for { + select { + case env := <-sender.frames: + if env.Type != proto.TypePreparationStatus || env.ID != id { + continue + } + var value proto.PreparationStatusPayload + if err := env.DecodePayload(&value); err != nil { + t.Fatal(err) + } + if value.State == state { + return value + } + if value.State == "rejected" || value.State == "failed" { + t.Fatalf("next admission failed: %+v", value) + } + case <-deadline: + t.Fatal("preparation status missing") + } + } + } + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + admit := func(id string) proto.PreparationStatusPayload { + t.Helper() + handle(proto.TypeExecutionPrepare, id, request) + return status(id, "ready") + } + start := func(id string, p proto.PreparationStatusPayload) *terminalHandoffTurn { + t.Helper() + handle(proto.TypeExecutionStart, id, proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: id, Input: proto.TextInput(id)}) + status(id, "started") + select { + case turn := <-owner.turns: + return turn + case <-time.After(time.Second): + t.Fatal("native Turn missing") + return nil + } + } + first := admit("first") + old := start("first", first) + r.mu.Lock() + handoff := r.sessions["first"].preparedHandoff + r.mu.Unlock() + old.finish() + select { + case <-sender.visible: + case <-time.After(time.Second): + t.Fatal("Done not visible") + } + // Simulate Core admitting its successor after seeing Done, while the old + // Send is still blocked. Exact native continuity must already be committed. + request.Configuration.AgentSessionID = "native" + request.Configuration.RequireExistingNativeSession = true + second := admit("second") + if second.ExecutorID != first.ExecutorID || !second.Reused || creates.Load() != 1 { + t.Fatal("settled owner was replaced") + } + next := start("second", second) + unblock() + r.mu.Lock() + settled := handoff.release.settled + r.mu.Unlock() + select { + case <-settled: + case <-time.After(time.Second): + t.Fatal("old publication did not finish") + } + r.mu.Lock() + current := r.executors["session"] + valid := current != nil && !current.invalid && current.run == r.sessions["second"] && current.admission == r.preparations[second.Handle] + deliveryErr := handoff.outputErr + r.mu.Unlock() + if !valid || r.ActiveRuns() != 1 || owner.closes.Load() != 0 { + t.Fatal("old terminal cleanup damaged its successor") + } + if (deliveryErr != nil) != fail { + t.Fatalf("delivery error=%v", deliveryErr) + } + next.finish() + // The second terminal does not block: observing it must likewise permit an + // immediate third admission without losing or recreating the retained owner. + for { + select { + case env := <-sender.frames: + if env.Type == proto.TypeDone && env.ID == "second" { + goto completed + } + case <-time.After(time.Second): + t.Fatal("second Done missing") + } + } + completed: + third := admit("third") + if third.ExecutorID != first.ExecutorID || !third.Reused || creates.Load() != 1 { + t.Fatal("successor failed to retain owner") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/executor_test.go b/apps/parsar-daemon/internal/dispatch/executor_test.go new file mode 100644 index 000000000..db02f9ff8 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_test.go @@ -0,0 +1,312 @@ +package dispatch_test + +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" +) + +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: proto.AgentKindCapabilities{EnvironmentNone: true}}, 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: proto.AgentKindCapabilities{EnvironmentNone: true}}, 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") + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/functions_native_test.go b/apps/parsar-daemon/internal/dispatch/functions_native_test.go index b47d0aec9..9502539ac 100644 --- a/apps/parsar-daemon/internal/dispatch/functions_native_test.go +++ b/apps/parsar-daemon/internal/dispatch/functions_native_test.go @@ -40,6 +40,9 @@ func TestNativeFunctionBridge(t *testing.T) { if err != nil { t.Fatal(err) } + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } t.Setenv("OAC_RUNTIME_HOME", home) var count atomic.Int32 model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go index c4ead6f33..711593393 100644 --- a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go +++ b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go @@ -266,9 +266,6 @@ func (r *Router) indexPermissionFrame(s *sessionState, env proto.Envelope) { return } requestID := strings.TrimSpace(p.RequestID) - if requestID == "" { - requestID = strings.TrimSpace(env.ID) - } if requestID == "" { return } diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index 0515a59d6..ca534f751 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -17,12 +17,10 @@ import ( ) func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n"), 0o700); err != nil { - t.Fatal(err) - } + workspace := t.TempDir() + environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, helper) + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } @@ -52,12 +50,20 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing if got := waitWorkspaceRead(t, sender, "list"); got.Outcome != "completed" || got.Directory == nil { t.Fatal("idle directory unavailable", got) } + if err := os.WriteFile(filepath.Join(workspace, "bytes"), []byte{0, 255, 17}, 0600); err != nil { + t.Fatal(err) + } + content := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Path: "bytes", MaxBytes: 2} + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "content", content)) + if got := waitWorkspaceRead(t, sender, "content"); got.Outcome != "completed" || !got.Truncated || !got.CloseAcknowledged || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal("file prefix unavailable", got) + } read.EnvironmentID = uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", read)) if got := waitWorkspaceRead(t, sender, "foreign"); got.Outcome != "rejected" { t.Fatal("foreign directory accepted", got) } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "forbidden", Input: proto.TextInput("work")})); err == nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "forbidden", Input: proto.TextInput("work")})); err == nil { t.Fatal("read preparation admitted execution") } bad := request @@ -92,21 +98,12 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa } func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - script := `#!/bin/sh -case "$2" in - missing) printf '%s' '{"version":1,"error":"not_directory"}' ;; - invalid) printf '%s' '{"version":1,"error":"invalid_path"}' ;; - gone) printf '%s' '{"version":1,"error":"not_found"}' ;; - denied) printf '%s' '{"version":1,"error":"permission_denied"}' ;; - broken) printf '%s' '{"version":1,"error":"native_error"}' ;; -esac -` - if err := os.WriteFile(helper, []byte(script), 0o700); err != nil { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "file"), nil, 0600); err != nil { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, helper) + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } @@ -129,11 +126,9 @@ esac } ready := waitPreparationStatus(t, sender, "idle", "ready", "") for path, want := range map[string]proto.WorkspaceReadResultPayload{ - "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, - "invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, - "gone": {Outcome: "rejected", ErrorCode: "not_found"}, - "denied": {Outcome: "rejected", ErrorCode: "permission_denied"}, - "broken": {Outcome: "unknown", ErrorCode: "read_unconfirmed"}, + "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "file": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "../invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, } { read := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Operation: "directory", Path: path, MaxEntries: 10} _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, path, read)) diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go index 127a7f96d..13bb7e76e 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -102,7 +102,7 @@ func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { t.Error("ordinary factory called") return nil, errors.New("unexpected") }) - h.reg.RegisterPreparation("prepared", false, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { entered <- struct{}{} return nil, errors.New("controlled stop") }) diff --git a/apps/parsar-daemon/internal/dispatch/native_file_results_test.go b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go new file mode 100644 index 000000000..e0f05f9e6 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go @@ -0,0 +1,53 @@ +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/google/uuid" + "io/fs" + "testing" +) + +func TestNativeDirectoryFailureMapping(t *testing.T) { + for _, test := range []struct { + err error + outcome, code string + }{ + {agent.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, + {agent.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, + {fs.ErrNotExist, "rejected", "not_found"}, + {fs.ErrPermission, "rejected", "permission_denied"}, + {agent.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, + } { + got := workspaceReadResult(agent.WorkspaceReadResult{}, test.err, 0) + if got.Outcome != test.outcome || got.ErrorCode != test.code { + t.Fatal(test.err, got) + } + } +} +func TestLocalUploadUnknownRetainsOwner(t *testing.T) { + sender := exportSender{make(chan proto.Envelope, 8)} + r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender}) + if err != nil { + t.Fatal(err) + } + got := workspaceWriteResult(agent.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) + if got.Outcome != "unknown" { + t.Fatal(got) + } + r.workspaceWrite = &workspaceUpload{envelope: proto.Envelope{ID: uuid.NewString()}, finished: true, uncertain: true} + request := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file", SizeBytes: 0, SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} + env, _ := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), request) + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + reply := <-sender.replies + var result proto.WorkspaceWriteResultPayload + if err = reply.DecodePayload(&result); err != nil || result.Outcome != "rejected" || result.ErrorCode != "write_capacity" { + t.Fatal(result, err) + } + if err = r.Shutdown(t.Context()); err == nil { + t.Fatal("shutdown declared uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go index acfe25be6..dd218cd88 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation.go +++ b/apps/parsar-daemon/internal/dispatch/preparation.go @@ -19,6 +19,7 @@ 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 { + executor *executorState requestID string trace string fingerprint [32]byte @@ -44,6 +45,9 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "invalid_request") } req := input.Configuration + if !req.WorkspaceReadOnly { + return r.handleExecutorPrepare(ctx, env, input) + } caps := r.availableCapabilities(req.AgentKind) prepare, err := r.registry.ResolvePreparation(req.AgentKind) if err != nil || !caps.Preparation { @@ -74,7 +78,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return ErrRouterClosed } - if (r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly { + if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -179,6 +183,11 @@ func (r *Router) releasePreparation(p *preparationState, state, code string, pub 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 { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go index d5fd98ec6..ecba30e74 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go @@ -3,7 +3,6 @@ package dispatch_test import ( "context" "errors" - "fmt" "reflect" "sync" "sync/atomic" @@ -49,7 +48,7 @@ func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recS t.Fatal(err) } ready := waitPreparationStatus(t, sender, "request", "ready", "") - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("input")})); err != nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")})); err != nil { t.Fatal(err) } return ready @@ -167,7 +166,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { } func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) { - for _, boundary := range []string{"start_failure", "release", "expiry", "shutdown"} { + for _, boundary := range []string{"start_failure", "release", "shutdown"} { t.Run(boundary, func(t *testing.T) { sender := &recSender{} entered, cancelled, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) @@ -195,7 +194,7 @@ func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) switch boundary { case "release": _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) - waitPreparationStatus(t, sender, "request", "released", "") + // An admission release does not terminate a handed-off Turn. case "expiry": waitPreparationStatus(t, sender, "request", "expired", "") case "shutdown": @@ -248,6 +247,9 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { startCancellationPreparation(t, r, sender.recSender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + if failure == "cancel_failed" { + waitFor(t, func() bool { return p.calls.Load() == 1 }, "failed pre-transfer cleanup") + } close(allowReturn) waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed cancellation") ack := cancellationAcks(sender.recSender)[0] @@ -255,17 +257,21 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { t.Fatalf("uncertain cancellation accepted: %+v", ack) } if failure == "cancel_failed" { - if r.ActiveRuns() != 1 || session.cancels() != 0 { - t.Fatal("failed attempt released ownership or used a fallback target") + waitFor(t, func() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.session != nil }, "late Turn returned") + if r.ActiveRuns() != 1 { + t.Fatal("unconfirmed cleanup lost owner") } _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})) - waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 && r.ActiveRuns() == 0 }, "same-target cancellation retry") - if retry := cancellationAcks(sender.recSender)[1]; !retry.Applied || retry.ErrorCode != "" || session.cancels() != 1 || p.calls.Load() != 2 { - t.Fatalf("same target was not retried exactly once: ack=%+v calls=%d session=%d", retry, p.calls.Load(), session.cancels()) + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 }, "cleanup retry receipt") + if !cancellationAcks(sender.recSender)[1].Applied { + t.Fatal("confirmed retry was rejected") } - } else if r.ActiveRuns() != 0 || session.cancels() != 1 { - t.Fatal("successful cleanup did not settle output failure") } + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "confirmed resource cleanup") + if session.cancels() != 1 { + t.Fatal("confirmed cleanup did not stop the native Turn") + } + }) } } @@ -310,11 +316,7 @@ func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) }) startCancellationPreparation(t, r, sender) <-entered - for i := 0; i < 3; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - waitPreparationStatus(t, sender, id, "ready", "") - } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) deadline := time.Now().Add(12 * time.Second) for len(cancellationAcks(sender)) == 0 && time.Now().Before(deadline) { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go index 2c3cd176b..2a51c0e8c 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go @@ -32,6 +32,9 @@ func (r *Router) closePendingPreparationsLocked() []*preparationState { var closeNow []*preparationState for _, p := range r.preparations { p.timer.Stop() + if p.executor != nil { + continue + } if !p.owns { continue } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go index 7c08293d6..a36355bd5 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go @@ -55,7 +55,7 @@ func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { if owned, _ := r.PreparationOwnershipForTest(ready.Handle); !owned { t.Fatal("cancel receipt discarded unsettled preparation") } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-retry"})) waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(ready.Handle); return !owned }, "retained cancellation cleanup") } @@ -69,7 +69,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return want } return session.Cancel(ctx) @@ -80,7 +80,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { if err := r.Shutdown(t.Context()); !errors.Is(err, want) { t.Fatalf("first shutdown lost native failure: %v", err) } - if r.ActiveRuns() != 1 || p.calls.Load() != 1 || session.cancels() != 0 { + if r.ActiveRuns() != 1 || p.calls.Load() != 2 || session.cancels() != 0 { t.Fatal("failed shutdown released ownership or changed release target") } select { @@ -91,78 +91,11 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { if err := r.Shutdown(t.Context()); err != nil { t.Fatal(err) } - if r.ActiveRuns() != 0 || p.calls.Load() != 2 || session.cancels() != 1 { + if r.ActiveRuns() != 0 || p.calls.Load() != 3 || session.cancels() != 1 { t.Fatalf("shutdown did not retry the same target once: active=%d target=%d session=%d", r.ActiveRuns(), p.calls.Load(), session.cancels()) } } -func TestPreparationCloseFailureRetainsCapacityAndRetries(t *testing.T) { - sender := &recSender{} - entered, release := make(chan struct{}), make(chan struct{}) - var once sync.Once - unblock := func() { once.Do(func() { close(release) }) } - p := &retryablePreparation{close: func(call int32) error { - if call == 1 { - return errors.New("cleanup incomplete") - } - if call == 2 { - close(entered) - <-release - } - return nil - }} - var factories atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - if factories.Add(1) == 1 { - return p, nil - } - return &controlledPreparation{closed: make(chan struct{})}, nil - }) - t.Cleanup(unblock) - var handle string - for i := range 4 { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - ready := waitPreparationStatus(t, sender, id, "ready", "") - if i == 0 { - handle = ready.Handle - } - } - request := mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: handle}) - _ = r.Handle(t.Context(), request) - waitFor(t, func() bool { _, busy := r.PreparationOwnershipForTest(handle); return p.calls.Load() == 1 && !busy }, "failed cleanup return") - if owned, _ := r.PreparationOwnershipForTest(handle); !owned { - t.Fatal("failed cleanup discarded ownership") - } - replacement := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) - if err := r.Handle(t.Context(), replacement); err == nil { - t.Fatal("failed cleanup released capacity") - } - start := proto.ExecutionStartPayload{Handle: handle, RunID: "run", Input: proto.TextInput("do not execute")} - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", start)); err == nil { - t.Fatal("failed cleanup allowed Start") - } - _ = r.Handle(t.Context(), request) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("release did not retry retained resource") - } - _ = r.Handle(t.Context(), request) - if p.calls.Load() != 2 { - t.Fatal("release started overlapping cleanup") - } - unblock() - waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(handle); return !owned }, "successful cleanup") - if err := r.Handle(t.Context(), replacement); err != nil { - t.Fatalf("settled cleanup retained capacity: %v", err) - } - waitPreparationStatus(t, sender, "replacement", "ready", "") - if p.starts.Load() != 0 { - t.Fatal("cleanup restarted native execution") - } -} - func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { want := errors.New("native cleanup incomplete") p := &retryablePreparation{close: func(call int32) error { @@ -174,18 +107,16 @@ func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { sender := &recSender{} 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())) - handle := waitPreparationStatus(t, sender, "request", "ready", "").Handle + waitPreparationStatus(t, sender, "request", "ready", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { t.Fatalf("shutdown lost cleanup error: %v", err) } - if owned, _ := r.PreparationOwnershipForTest(handle); !owned { - t.Fatal("shutdown discarded unsettled resource") - } + if err := r.Shutdown(t.Context()); err != nil { t.Fatal(err) } - if owned, _ := r.PreparationOwnershipForTest(handle); owned || p.calls.Load() != 2 { - t.Fatalf("retry did not settle original resource: owned=%t calls=%d", owned, p.calls.Load()) + if p.calls.Load() != 2 { + t.Fatalf("retry did not settle original resource: calls=%d", p.calls.Load()) } } @@ -239,7 +170,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return errors.New("cleanup incomplete") } return session.Cancel(ctx) @@ -260,7 +191,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { } // A later admission retires the expired preparation record. The Run still // owns the exact cancellation target independently of that old handle. - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest())); err != nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", readOnlyPreparationRequest())); err != nil { t.Fatal(err) } waitPreparationStatus(t, sender, "replacement", "ready", "") @@ -278,7 +209,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { } } } - if p.calls.Load() != 2 || session.cancels() != 1 || r.ActiveRuns() != 0 { + if p.calls.Load() != 3 || session.cancels() != 1 || r.ActiveRuns() != 0 { t.Fatal("Run cancellation did not retry and settle the same target") } select { @@ -287,3 +218,9 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { default: } } + +func readOnlyPreparationRequest() proto.ExecutionPreparePayload { + req := preparationRequest() + req.Configuration.WorkspaceReadOnly = true + return req +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go new file mode 100644 index 000000000..c82ac841e --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -0,0 +1,133 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Old fault-injection fixtures model disposable executors, not reusable native implementations. +func preparationExecutorFixture(factory agent.PreparationFactory) agent.ExecutorFactory { + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + prepared, err := factory(ctx, req) + if prepared == nil { + return nil, err + } + owner := &preparationExecutor{prepared: prepared} + if _, ok := prepared.(agent.PreparedCancellation); !ok { + return owner, errors.New("fixture has no cancellation target") + } + return owner, err + } +} + +type preparationExecutor struct { + prepared agent.Prepared + mu sync.Mutex + cancelled bool +} + +func (e *preparationExecutor) Close(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.cancelled { + return nil + } + var err error + if resource, ok := e.prepared.(*retryablePreparation); ok { + err = resource.Close() + } else if cancel, ok := e.prepared.(agent.PreparedCancellation); ok { + err = cancel.Cancel(ctx) + } else { + err = e.prepared.Close() + } + e.cancelled = err == nil + return err +} + +func (e *preparationExecutor) StartTurn(ctx context.Context, id string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + upstream := make(chan proto.Envelope, 64) + terminal := make(chan struct{}) + var once sync.Once + go func() { + defer close(out) + defer once.Do(func() { close(terminal) }) + for event := range upstream { + if event.Type == proto.TypeDone { + once.Do(func() { close(terminal) }) + } + out <- event + } + }() + session, err := e.prepared.Start(ctx, id, input, upstream) + if session == nil { + close(upstream) + // This fixture's old Start may have produced output before rejecting; retain its Turn. + return &preparationTurn{owner: e, settled: terminal}, err + } + return &preparationTurn{Session: session, owner: e, settled: terminal}, err +} + +type preparationTurn struct { + agent.Session + owner *preparationExecutor + settled <-chan struct{} +} + +func (t *preparationTurn) Cancel(ctx context.Context) error { return t.owner.Close(ctx) } +func (t *preparationTurn) CancellationOutcome() proto.DonePayload { + if target, ok := t.owner.prepared.(agent.PreparedCancellation); ok { + return target.CancellationOutcome() + } + return proto.DonePayload{} +} +func (t *preparationTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-t.settled: + return agent.TurnSettlement{Reason: "disposable fault fixture"}, t.owner.Close(ctx) + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} +func (t *preparationTurn) SubmitFunctionResult(ctx context.Context, p proto.FunctionResultPayload) error { + if target, ok := t.Session.(agent.FunctionResultSubmitter); ok { + return target.SubmitFunctionResult(ctx, p) + } + return agent.ErrUnknownFunctionCall +} +func (t *preparationTurn) Steer(ctx context.Context, p proto.PromptSteerPayload) error { + if target, ok := t.Session.(agent.Steerer); ok { + return target.Steer(ctx, p) + } + return agent.ErrSteeringInactive +} +func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSteerPayload, write func()) error { + if target, ok := t.Session.(agent.DurableSteerer); ok { + return target.SteerWithReceipt(ctx, p, write) + } + if target, ok := t.Session.(agent.Steerer); ok { + return target.Steer(ctx, p) + } + return agent.ErrSteeringInactive +} +func (t *preparationTurn) SubmitPermission(ctx context.Context, id string, p proto.PermissionDecisionPayload) error { + if target, ok := t.Session.(agent.PermissionResponder); ok { + return target.SubmitPermission(ctx, id, p) + } + return agent.ErrUnknownPermission +} +func (t *preparationTurn) SubmitPromptForUserChoice(ctx context.Context, id string, p proto.PromptForUserChoiceDecisionPayload) error { + if target, ok := t.Session.(agent.UserChoiceResponder); ok { + return target.SubmitPromptForUserChoice(ctx, id, p) + } + return agent.ErrUnknownAsk +} +func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + if target, ok := t.Session.(agent.WorkspaceReader); ok { + return target.ReadWorkspaceFile(ctx, path, limit) + } + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/parsar-daemon/internal/dispatch/preparation_start.go index 0607d407f..74d267cac 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_start.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_start.go @@ -14,17 +14,17 @@ import ( func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) error { var input proto.ExecutionStartPayload - if env.DecodePayload(&input) != nil || input.Handle == "" || strings.TrimSpace(input.RunID) == "" || input.Input.Validate() != nil { + if env.DecodePayload(&input) != nil || input.Handle == "" || input.ExecutorID == "" || strings.TrimSpace(input.RunID) == "" || input.Input.Validate() != nil { return r.rejectPreparation(env, "invalid_start") } encoded, _ := json.Marshal(input) fingerprint := sha256.Sum256(encoded) r.mu.Lock() - if r.closed { + if r.closed || r.suspension != nil { r.mu.Unlock() return ErrRouterClosed } - if r.workspaceExport != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -37,23 +37,25 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return r.rejectPreparation(env, "read_only_preparation") } + owner := p.executor + if owner == nil || owner.id != input.ExecutorID { + r.mu.Unlock() + return r.rejectPreparation(env, "unknown_executor") + } if p.status.State == "starting" || p.status.State == "started" { matches, status := p.startFingerprint == fingerprint, p.status - if matches { - if state := r.sessions[input.RunID]; state != nil && state.preparedHandoff != nil && - (state.session == nil || state.preparedHandoff.release != nil) { - r.mu.Unlock() - return nil - } - } + pending := p.handoff != nil && !p.handoff.published r.mu.Unlock() if !matches { return r.rejectPreparation(env, "start_conflict") } + if pending { + return nil + } r.publishPreparation(p, status) return nil } - if p.status.State != "ready" || p.ctx.Err() != nil { + if p.status.State != "ready" || p.ctx.Err() != nil || owner.admission != p || owner.run != nil || owner.invalid || owner.native == nil { r.mu.Unlock() return r.rejectPreparation(env, "preparation_not_ready") } @@ -66,24 +68,13 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return r.rejectPreparation(env, "run_conflict") } - target, ok := p.prepared.(agent.PreparedCancellation) - if !ok { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_not_ready") - } p.status.State, p.status.RunID, p.status.Revision = "starting", input.RunID, p.status.Revision+1 p.startFingerprint, p.busy = fingerprint, true - state := &sessionState{ - runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, - out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, - pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), - traceparent: env.Trace, releaseOnCompletion: true, - } - state.preparedHandoff = newPreparedHandoff(p, target) - p.handoff = state.preparedHandoff + state := &sessionState{runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} + state.preparedHandoff = newPreparedHandoff(p, owner.native) + p.handoff, owner.run = state.preparedHandoff, state r.sessions[input.RunID] = state status := p.status - // Track the Start owner and output consumer before either can publish. r.shutdownWG.Add(2) r.mu.Unlock() go r.startPreparedExecution(p, state, input, status) @@ -95,100 +86,74 @@ func (r *Router) startPreparedExecution(p *preparationState, state *sessionState handoff := state.preparedHandoff go r.forwardPreparedOutput(state) <-handoff.outputReady - - if !r.sendPreparation(p.requestID, p.trace, starting) { - r.mu.Lock() - handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) - if p.status.State == "starting" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 - } - p.timer.Stop() - r.claimPreparedReleaseLocked(state, true, "", false) - r.mu.Unlock() - close(state.out) - close(handoff.startDone) - return - } - - // A release admitted before this point must not cause native work to start. + delivered := r.sendPreparation(p.requestID, p.trace, starting) r.mu.Lock() - blocked := handoff.release != nil && handoff.release.aborted() + blocked := !delivered || handoff.release != nil && handoff.release.aborted() || p.ctx.Err() != nil r.mu.Unlock() - var session agent.Session + var turn agent.Turn var startErr error if blocked { startErr = context.Canceled } else { - session, startErr = handoff.target.Start(p.ctx, input.RunID, input.Input, state.out) + turn, startErr = handoff.target.StartTurn(p.ctx, input.RunID, input.Input, state.out) } - r.mu.Lock() - release := handoff.release - notAborted := release == nil || !release.aborted() - started := startErr == nil && session != nil && p.status.State == "starting" && p.ctx.Err() == nil && notAborted - if started { + handoff.turn = turn + handoff.startErr = startErr + p.timer.Stop() + p.busy = false + if turn != nil { p.status.State, p.status.ErrorCode, p.status.Revision = "started", "", p.status.Revision+1 } else { - p.timer.Stop() - if p.status.State == "starting" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 - } - r.claimPreparedReleaseLocked(state, true, "prepared execution could not start", false) + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "executor_unavailable", p.status.Revision+1 } status := p.status - // Keep release behind successful or failed status publication. This also - // orders an early native Done after the started status. - if started { - handoff.operations.RLock() - } r.mu.Unlock() - - // A nil Session leaves output ownership with the Router. A non-nil Session - // owns the close even when Start also returned an error. - if session == nil { + if turn == nil { close(state.out) - } - deadline := time.Now().Add(5 * time.Second) - if started && p.deadline.Before(deadline) { - deadline = p.deadline - } - delivered := r.sendPreparationUntil(p.requestID, p.trace, status, deadline) - - r.mu.Lock() - if started && !time.Now().Before(p.deadline) { - if p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "expired", "", p.status.Revision+1 + code := "executor_unavailable" + closeErr := r.closeExecutor(p.executor) + if closeErr != nil { + code = "executor_cleanup_unconfirmed" + } + if blocked { + code = "start_cancelled" + } + r.mu.Lock() + p.status.ErrorCode = code + release, _ := r.claimPreparedReleaseLocked(state, true, "", false) + close(handoff.startDone) + r.mu.Unlock() + if closeErr == nil { + <-release.settled + } + status = proto.PreparationStatusPayload{Handle: p.status.Handle, ExecutorID: p.executor.id, State: "rejected", ErrorCode: code, Operation: proto.TypeExecutionStart} + if delivered { + r.sendPreparation(p.requestID, p.trace, status) } - r.claimPreparedReleaseLocked(state, true, "", false) + return } - if started && handoff.release != nil && handoff.release.aborted() && p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 + r.mu.Lock() + aborted := handoff.release != nil && handoff.release.aborted() + r.mu.Unlock() + if aborted { + status.State, status.ErrorCode = "failed", "start_cancelled" } - if !delivered { + delivered = delivered && !aborted && r.sendPreparationUntil(p.requestID, p.trace, status, p.deadline) + r.mu.Lock() + if !delivered && !aborted { handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) - if started && p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 - } - r.claimPreparedReleaseLocked(state, true, "", false) - } else if started && p.status.State == "started" && - (handoff.release == nil || !handoff.release.aborted()) { - handoff.published = true - p.timer.Stop() } + handoff.published = turn != nil && delivered if handoff.published { - // Publication transfers resource tracking even when natural completion - // has already closed input admission. - if handoff.release == nil { - state.session = session - } - p.prepared = nil p.owns = false - p.busy = false - p.handoff = nil + } + if handoff.published && handoff.release == nil { + state.session = turn + } + if turn == nil || startErr != nil || !delivered { + r.claimPreparedReleaseLocked(state, true, "prepared execution could not start", false) } close(handoff.startDone) r.mu.Unlock() - if started { - handoff.operations.RUnlock() - } } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go index 4dee25e40..243e2b8df 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -3,9 +3,7 @@ package dispatch_test import ( "context" "errors" - "fmt" "os" - "path/filepath" "sync" "sync/atomic" "testing" @@ -14,6 +12,7 @@ import ( "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" ) @@ -32,7 +31,9 @@ func (p *controlledPreparation) Close() error { if p.closeHook != nil { p.closeHook() } - close(p.closed) + if p.closed != nil { + close(p.closed) + } }) return nil } @@ -72,20 +73,18 @@ const preparationSessionID = "22222222-2222-4222-8222-222222222222" func preparationWorkspace(t *testing.T) *localworkspace.Binding { t.Helper() - helper := filepath.Join(t.TempDir(), "directory") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[{\"name\":\"file\",\"kind\":\"file\",\"size_bytes\":3}],\"truncated\":true}}'\n"), 0o700); err != nil { + 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_DIRECTORY_HELPER": helper, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - "OAC_RUNTIME_ALLOWED_DOMAINS": "", - "OAC_RUNTIME_WRITE_HELPER": "", - "OAC_RUNTIME_EXPORT_HELPER": "", - "OAC_RUNTIME_STAGING": "", + "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) } @@ -111,7 +110,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled"}}} + 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 { @@ -121,6 +120,7 @@ func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Durati 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) @@ -225,7 +225,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { if err := r.Handle(t.Context(), prepare); err != nil { t.Fatal(err) } - start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "real-run", Input: proto.TextInput("actual input")}) + 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) } @@ -276,7 +276,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { 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, RunID: "real-run", Input: proto.TextInput("input")})) + _ = 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) @@ -307,37 +307,6 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { } } -func TestPreparationCapacityAndConnectionOwnership(t *testing.T) { - sender := &recSender{} - var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - count.Add(1) - return &controlledPreparation{closed: make(chan struct{})}, nil - }) - var handles []string - for i := 0; i < 4; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - handles = append(handles, waitPreparationStatus(t, sender, id, "ready", "").Handle) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", preparationRequest())); err == nil { - t.Fatal("capacity unbounded") - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "0", preparationRequest())); err != nil { - t.Fatal(err) - } - if count.Load() != 4 { - t.Fatal("retry recreated native resource") - } - other := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - t.Error("unexpected new native resource") - return nil, errors.New("unexpected") - }) - if err := other.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", proto.ExecutionStartPayload{Handle: handles[0], RunID: "run", Input: proto.TextInput("input")})); err == nil { - t.Fatal("another connection consumed handle") - } -} - func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { sender := &recSender{} created := make(chan *controlledPreparation, 2) @@ -350,13 +319,18 @@ func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { _ = r.Handle(t.Context(), env) old := waitPreparationStatus(t, sender, "request", "ready", "") waitPreparationStatus(t, sender, "request", "expired", "") - waitPreparationClosed(t, <-created) + 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", "preparing", old.Handle) + 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, RunID: "late", Input: proto.TextInput("late")})); err == nil { + 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") } } @@ -371,61 +345,25 @@ func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { return s.recSender.Send(ctx, env) } -func TestPreparationFailedReadyDeliveryClosesResource(t *testing.T) { +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) { return p, nil }) + 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 TestPreparationCapacityIncludesClosingResources(t *testing.T) { - sender := &recSender{} - entered, unblock := make(chan struct{}), make(chan struct{}) - var releaseOnce sync.Once - release := func() { releaseOnce.Do(func() { close(unblock) }) } - defer release() - var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - p := &controlledPreparation{closed: make(chan struct{})} - if count.Add(1) == 1 { - p.closeHook = func() { close(entered); <-unblock } - } - return p, nil - }) - var first proto.PreparationStatusPayload - for i := 0; i < 4; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - ready := waitPreparationStatus(t, sender, id, "ready", "") - if i == 0 { - first = ready - } - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: first.Handle})) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("cleanup did not start") - } - request := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) - if err := r.Handle(t.Context(), request); err == nil { - t.Fatal("closing resource returned capacity early") - } - release() - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - if err := r.Handle(t.Context(), request); err == nil { - waitPreparationStatus(t, sender, "replacement", "ready", "") - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("completed cleanup did not release capacity") -} - func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, @@ -437,7 +375,6 @@ func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { }, "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, - "release": func(p *proto.PromptRequestPayload) { p.ReleaseOnCompletion = false }, } { t.Run(name, func(t *testing.T) { r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go index b5416df44..89c3d1862 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -40,19 +40,21 @@ func (release *preparedRelease) aborted() bool { } } -// preparedHandoff owns the exact PreparedCancellation from Start admission -// through native cleanup and output settlement. It never falls back to the -// returned Session or Prepared.Close. +// preparedHandoff owns one Turn admission and settlement on a retained Executor. type preparedHandoff struct { preparation *preparationState - target agent.PreparedCancellation + target agent.Executor + turn agent.Turn + startErr error + protocolErr error startDone chan struct{} outputReady chan struct{} outputDone chan struct{} // Every admitted native mutation holds a read lock through its receipt. - // A release attempt takes the write lock before native cancellation. + // Release joins these receipts after native cancellation/settlement, since + // an admitted receipt may itself require cancellation to finish. operations sync.RWMutex published bool // started status committed; protected by Router.mu @@ -62,7 +64,7 @@ type preparedHandoff struct { shutdownInterrupted bool } -func newPreparedHandoff(p *preparationState, target agent.PreparedCancellation) *preparedHandoff { +func newPreparedHandoff(p *preparationState, target agent.Executor) *preparedHandoff { return &preparedHandoff{ preparation: p, target: target, @@ -116,6 +118,9 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai } if abort && !release.aborted() { close(release.abort) + if handoff.turn == nil { + handoff.preparation.cancel() + } } if release.succeeded { return release, release.attempt @@ -142,64 +147,144 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt) { defer r.shutdownWG.Done() - - // Natural completion must publish started before it can release the native - // resource. Abort is allowed to fence a Start that is still in progress. select { case <-handoff.startDone: case <-release.abort: + select { + case <-handoff.startDone: + default: + if err := r.closeExecutor(handoff.preparation.executor); err != nil { + r.mu.Lock() + attempt.err = err + handoff.preparation.busy = false + handoff.preparation.closeErr = err + close(attempt.done) + r.mu.Unlock() + return + } + } + <-handoff.startDone } - handoff.operations.Lock() - ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) - nativeErr := handoff.target.Cancel(ctx) - cancel() + // Admission is closed, but native cancellation must be able to release an + // already-written input or tool operation that is waiting for its receipt. + turn := handoff.turn + var settlement agent.TurnSettlement + var nativeErr error var outcome *proto.DonePayload + if turn == nil { + outcome = &proto.DonePayload{} + } + if turn != nil { + ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) + cancelDone := make(chan error, 1) + settled := make(chan struct{}) + go func() { + select { + case <-release.abort: + err := turn.Cancel(ctx) + if err != nil { + cancel() + } + cancelDone <- err + case <-settled: + cancelDone <- nil + } + }() + settlement, nativeErr = turn.AwaitSettlement(ctx) + close(settled) + nativeErr = errors.Join(nativeErr, <-cancelDone) + cancel() + if nativeErr == nil { + value := turn.CancellationOutcome() + outcome = &value + } + } if nativeErr == nil { - observed := handoff.target.CancellationOutcome() - outcome = &observed + <-handoff.outputDone + } + r.mu.Lock() + owner := handoff.preparation.executor + if turn != nil && handoff.terminal == nil && !release.aborted() { + handoff.protocolErr = errors.New("executor output ended without a terminal result") + } + if handoff.protocolErr != nil { + release.failure = handoff.protocolErr.Error() + } + invalidate := nativeErr != nil || !settlement.Reusable || handoff.startErr != nil || handoff.outputErr != nil || handoff.protocolErr != nil || r.closed || owner.invalid + if invalidate { + owner.invalid = true + } + r.mu.Unlock() + var closeErr error + if invalidate { + closeErr = r.closeExecutor(owner) + // A failed Turn settlement is not repaired into a cancellation receipt by Close. + nativeErr = errors.Join(nativeErr, closeErr) + } + if closeErr == nil { + // Native settlement (or confirmed resource close after failure) comes + // first. Keep every admitted operation and its outbound receipt owned + // until it returns; neither cancellation acknowledgement nor reuse can + // pass this join. Failed Close retains the same owner for a later retry. + handoff.operations.Lock() + handoff.operations.Unlock() + <-handoff.outputDone } - handoff.operations.Unlock() - r.mu.Lock() attempt.err = nativeErr - if nativeErr != nil { + if closeErr != nil { handoff.preparation.busy = false handoff.preparation.closeErr = nativeErr close(attempt.done) r.mu.Unlock() return } - release.succeeded = true - release.outcome = outcome + if nativeErr != nil { + release.failure = "executor Turn settlement failed" + } + release.succeeded, release.outcome = true, outcome close(attempt.done) r.mu.Unlock() - - // Cancel success promises local cleanup and no further output writes. Wait - // for Start and the one output consumer before publishing terminal state. - <-handoff.startDone - <-handoff.outputDone - r.mu.Lock() - outputErr := handoff.outputErr - terminal := handoff.terminal - closed := r.closed + outputErr, terminal, closed := handoff.outputErr, handoff.terminal, r.closed + r.mu.Unlock() + // Done can become visible before Sender.Send returns. Commit the settled + // owner and retire this Run before publication so an immediate successor + // observes both the new native identity and an available Executor. + r.cleanupSession(state) + r.mu.Lock() + p := handoff.preparation + p.busy, p.owns, p.handoff = false, false, nil + p.cancel() + if owner.run == state { + owner.run = nil + } + if owner.invalid && owner.closeDone != nil && owner.closeErr == nil && r.executors[owner.sessionID] == owner { + delete(r.executors, owner.sessionID) + } + if owner.admission == p { + owner.admission = nil + } + if outcome != nil { + if id, ok := outcome.Metadata[proto.DoneMetaAgentSessionID].(string); ok && id != "" { + owner.nativeID = id + } + } + if !owner.invalid { + r.scheduleExecutorIdleLocked(owner) + } r.mu.Unlock() var terminalErr error - if outputErr == nil && !closed { - terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, release.failure != "" && terminal == nil) + if outputErr == nil && !closed && turn != nil { + terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, terminal == nil) } - r.cleanupSession(state) - r.mu.Lock() + // Delivery failure belongs to this Run, not to a successor that may have + // already acquired the settled owner. Connection shutdown owns transport + // failure cleanup; terminal publication does not change native settlement. handoff.outputErr = errors.Join(handoff.outputErr, terminalErr) - p := handoff.preparation - p.busy = false p.closeErr = terminalErr - p.prepared = nil - p.owns = false - p.handoff = nil - p.cancel() close(release.settled) r.mu.Unlock() } @@ -230,7 +315,9 @@ func (r *Router) forwardPreparedOutput(state *sessionState) { return } r.mu.Lock() - if handoff.terminal != nil { + if env.ID != state.runID || handoff.terminal != nil { + handoff.protocolErr = errors.New("executor output crossed the Turn boundary") + r.claimPreparedReleaseLocked(state, true, handoff.protocolErr.Error(), false) r.mu.Unlock() continue } diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go index fa0717b4f..fce4c8640 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -15,16 +15,12 @@ type preparedMutationSession struct { *fakeSession cancelEntered chan struct{} cancelOnce sync.Once - beforeCancel func() functions atomic.Int32 steers atomic.Int32 reads atomic.Int32 } func (s *preparedMutationSession) Cancel(ctx context.Context) error { - if s.beforeCancel != nil { - s.beforeCancel() - } s.cancelOnce.Do(func() { close(s.cancelEntered) }) return s.fakeSession.Cancel(ctx) } @@ -162,8 +158,11 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { } select { case <-session.cancelEntered: - t.Fatal("native release overtook an admitted receipt") - case <-time.After(50 * time.Millisecond): + case <-time.After(2 * time.Second): + t.Fatal("native cleanup was blocked by the outbound receipt") + } + if r.ActiveRuns() != 1 || hasFrame(sender.recSender, proto.TypeDone, "run") { + t.Fatal("Run ownership or Done overtook the admitted receipt") } close(sender.release) select { @@ -179,7 +178,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("release did not follow the completed receipt") } - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "mutation release cleanup") + waitFor(t, func() bool { return r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") }, "mutation release cleanup") if session.cancels() != 1 { t.Fatalf("Session release calls = %d, want 1", session.cancels()) } @@ -239,7 +238,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { t.Run(operation, func(t *testing.T) { sender := &blockingPreparedReceiptSender{recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} defer close(sender.release) - var cancelBeforeReceipt atomic.Bool session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} p := &controlledPreparation{closed: make(chan struct{})} p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { @@ -250,13 +248,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") - session.beforeCancel = func() { - select { - case <-sender.exited: - default: - cancelBeforeReceipt.Store(true) - } - } var mutation proto.Envelope switch operation { case "function": @@ -283,8 +274,13 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { if err := r.Shutdown(ctx); err != nil { t.Fatal(err) } - if cancelBeforeReceipt.Load() || session.cancels() != 1 || r.ActiveRuns() != 0 { - t.Fatalf("shutdown crossed receipt barrier: early=%t cancels=%d active=%d", cancelBeforeReceipt.Load(), session.cancels(), r.ActiveRuns()) + select { + case <-sender.exited: + default: + t.Fatal("shutdown returned before the receipt attempt ended") + } + if session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatalf("shutdown did not join native cleanup: cancels=%d active=%d", session.cancels(), r.ActiveRuns()) } }) } diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go index d12c8246e..db420f8dc 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go @@ -208,7 +208,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 || len(session.submissions()) != 0 || askCalls != 0 { t.Fatal("private Session accepted work before started publication") } - duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("input")}) + duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")}) if err := r.Handle(t.Context(), duplicate); err != nil { t.Fatal(err) } @@ -315,7 +315,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return errors.New("cleanup incomplete") } return session.Cancel(ctx) @@ -327,7 +327,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { waitPreparationStatus(t, sender, "request", "started", "") waitFor(t, func() bool { _, busy := r.PreparationOwnershipForTest(ready.Handle) - return p.calls.Load() == 1 && !busy + return p.calls.Load() == 2 && !busy }, "failed natural cleanup") if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { t.Fatal("published Run retained preparation capacity after early Done") @@ -345,14 +345,14 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { } return count == 2 }, "published preparation release response") - if p.calls.Load() != 1 { + if p.calls.Load() != 2 { t.Fatal("old preparation handle retried native cancellation") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(cancellationAcks(sender)) == 1 && r.ActiveRuns() == 0 }, "Run-owned retry settlement") - if p.calls.Load() != 2 || session.cancels() != 1 || !cancellationAcks(sender)[0].Applied { + if p.calls.Load() != 3 || session.cancels() != 1 || !cancellationAcks(sender)[0].Applied { t.Fatal("explicit Run cancellation did not retry the retained native target") } } diff --git a/apps/parsar-daemon/internal/dispatch/prompt.go b/apps/parsar-daemon/internal/dispatch/prompt.go index f92d972ca..e0b4c07d4 100644 --- a/apps/parsar-daemon/internal/dispatch/prompt.go +++ b/apps/parsar-daemon/internal/dispatch/prompt.go @@ -5,6 +5,7 @@ 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" ) @@ -74,7 +75,7 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil { + if r.runtimePreparation != nil || r.workspaceWrite != nil || r.workspaceExport != nil { r.mu.Unlock() err := errors.New("local workspace has an unsettled write") r.emitTerminalError(callerCtx, runID, err.Error()) @@ -111,7 +112,11 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo r.mu.Unlock() r.log.InfoContext(callerCtx, "handlePromptRequest: calling factory", "run_id", runID) - sess, err := factory(sessionCtx, req, out) + req, err = r.localWorkspace.Prepare(sessionCtx, req) + var sess agent.Session + if err == nil { + sess, err = factory(sessionCtx, req, out) + } if err != nil { r.log.ErrorContext(callerCtx, "handlePromptRequest: factory call failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) // Roll back the registration, cancel ctx, surface error+done diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go index 61c97f360..0a8a4ca9d 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -47,9 +47,11 @@ type Router struct { shutdownWG dispatchWork // waits for all pump goroutines idleTimeout time.Duration closed bool + executors map[string]*executorState preparations map[string]*preparationState preparationRequests map[string]*preparationState preparationTimeout time.Duration + runtimePreparation *runtimePreparationTransfer workspaceWrite *workspaceUpload workspaceExport *workspaceExport workspaceReads map[string]struct{} @@ -135,6 +137,7 @@ func New(cfg Config) (*Router, error) { applied: make(map[string]appliedInteractionDecision), shutdownCh: make(chan struct{}), idleTimeout: idleTimeout, + executors: make(map[string]*executorState), preparations: make(map[string]*preparationState), preparationRequests: make(map[string]*preparationState), preparationTimeout: preparationTimeout, @@ -165,6 +168,8 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { ctx = adoptEnvelopeTrace(ctx, env) switch env.Type { + case proto.TypeRuntimePrepare: + return r.handleRuntimePrepare(ctx, env) case proto.TypeWorkspaceExport: return r.handleWorkspaceExport(ctx, env) case proto.TypeWorkspaceWrite: diff --git a/apps/parsar-daemon/internal/dispatch/router_test.go b/apps/parsar-daemon/internal/dispatch/router_test.go index 028d6dbc2..a636c622e 100644 --- a/apps/parsar-daemon/internal/dispatch/router_test.go +++ b/apps/parsar-daemon/internal/dispatch/router_test.go @@ -436,8 +436,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { // payload.AskID to seed askIndex. askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask", proto.PromptForUserChoicePayload{ AskID: "ask_abcd1234", - Question: "?", - Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}, + Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}}}, ToolUseID: "toolu_42", }) sess.out <- askEnv @@ -492,8 +491,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask_u", proto.PromptForUserChoicePayload{ AskID: "ask_xxxxxxxx", - Question: "?", - Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}, + Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}}}, ToolUseID: "toolu_y", }) sess.out <- askEnv diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation.go new file mode 100644 index 000000000..9975e9a41 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation.go @@ -0,0 +1,197 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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/google/uuid" +) + +const runtimePreparationTimeout = 120 * time.Second + +// Router.mu protects one connection-local transfer. Partial installation data +// belongs to the bound Environment and is never removed by transfer cleanup. +type runtimePreparationTransfer struct { + envelope proto.Envelope + request proto.RuntimePreparePayload + data []byte + ready chan struct{} + cancel context.CancelFunc + finished bool + apply bool + uncertain bool +} + +func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) error { + id, err := uuid.Parse(env.ID) + if err != nil || id == uuid.Nil || id.String() != env.ID { + return errors.New("dispatch: invalid Runtime preparation identity") + } + var request proto.RuntimePreparePayload + if len(env.Payload) > proto.RuntimePrepareMaxFrameBytes || env.DecodePayload(&request) != nil || !proto.ValidRuntimePrepareRequest(request) { + r.mu.Lock() + pending := r.runtimePreparation != nil && r.runtimePreparation.envelope.ID == env.ID + if pending && !r.runtimePreparation.finished { + r.finishRuntimePreparationTransferLocked(r.runtimePreparation, false) + } + r.mu.Unlock() + if pending { + // A late malformed frame cannot report rejection of an earlier commit. + return errors.New("dispatch: malformed pending Runtime preparation frame") + } + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("invalid_request")) + } + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return ErrRouterClosed + } + if request.Step == "begin" { + if r.runtimePreparation != nil { + duplicate := r.runtimePreparation.envelope.ID == env.ID + r.mu.Unlock() + if duplicate { + return errors.New("dispatch: Runtime preparation already admitted") + } + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("runtime_preparation_capacity")) + } + if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID, request.SessionID) || r.runtimePreparationResourcesBusyLocked() { + r.mu.Unlock() + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable")) + } + owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), runtimePreparationTimeout) + u := &runtimePreparationTransfer{ + envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), + ready: make(chan struct{}), cancel: cancel, + } + r.runtimePreparation = u + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.runRuntimePreparationTransfer(owner, u, r.localWorkspace.ApplyRuntimePreparation) + if err := r.sendRuntimePrepareResult(ctx, env.ID, proto.RuntimePrepareResultPayload{Outcome: "ready"}); err != nil { + cancel() + return err + } + return nil + } + u := r.runtimePreparation + if u == nil || u.envelope.ID != env.ID { + r.mu.Unlock() + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable")) + } + if u.finished { + r.mu.Unlock() + return errors.New("dispatch: Runtime preparation body already closed") + } + if request.Step == "chunk" && request.Offset == len(u.data) && len(request.Data) <= u.request.SizeBytes-len(u.data) { + u.data = append(u.data, request.Data...) + offset := len(u.data) + r.mu.Unlock() + if err := r.sendRuntimePrepareResult(ctx, env.ID, proto.RuntimePrepareResultPayload{Outcome: "received", Offset: offset}); err != nil { + u.cancel() + return err + } + return nil + } + apply := false + if request.Step == "commit" && len(u.data) == u.request.SizeBytes { + if u.request.Action == "finalize" || u.request.Action == "initialize" { + apply = true + } else { + digest := sha256.Sum256(u.data) + apply = hex.EncodeToString(digest[:]) == u.request.SHA256 + } + } + r.finishRuntimePreparationTransferLocked(u, apply) + r.mu.Unlock() + return nil +} + +func (r *Router) runtimePreparationResourcesBusyLocked() bool { + if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { + return true + } + for _, p := range r.preparations { + if p.owns || p.busy { + return true + } + } + return false +} + +func (r *Router) finishRuntimePreparationTransferLocked(u *runtimePreparationTransfer, apply bool) { + u.apply, u.finished = apply, true + close(u.ready) +} + +// apply must return only after its local mutations stop. Cancellation requests +// shutdown, but cannot release ownership while that call is still running. +func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePreparationTransfer, apply func(context.Context, proto.RuntimePreparePayload, []byte) error) { + defer r.shutdownWG.Done() + defer u.cancel() + select { + case <-u.ready: + case <-r.shutdownCh: + case <-ctx.Done(): + } + r.mu.Lock() + admitted := u.apply && !r.closed && ctx.Err() == nil + u.finished = true + data := u.data + u.data = nil + r.mu.Unlock() + result := rejectedRuntimePreparation("invalid_request") + if admitted { + result = runtimePreparationResult(apply(ctx, u.request, data), u.request.SizeBytes) + } + // Release the potentially large body before waiting on transport delivery. + data = nil + r.mu.Lock() + u.uncertain = result.Outcome == "unknown" + if !u.uncertain && r.runtimePreparation == u { + r.runtimePreparation = nil + } + r.mu.Unlock() + // The result has a separate send budget, independent of an installation timeout. + _ = r.sendRuntimePrepareResult(context.WithoutCancel(ctx), u.envelope.ID, result) +} + +func rejectedRuntimePreparation(code string) proto.RuntimePrepareResultPayload { + return proto.RuntimePrepareResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func runtimePreparationResult(err error, size int) proto.RuntimePrepareResultPayload { + if err == nil { + return proto.RuntimePrepareResultPayload{Outcome: "completed", SizeBytes: size} + } + var initialization *localworkspace.InitializationFailure + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.As(err, &initialization) { + code := 0 + if initialization.ExitCode != nil { + code = *initialization.ExitCode + } + if (initialization.ExitCode == nil || code > 0) && code <= 255 { + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: code} + } + } + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.Is(err, agentcapabilities.ErrInvalid) { + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed"} + } + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} +} + +func (r *Router) sendRuntimePrepareResult(ctx context.Context, id string, result proto.RuntimePrepareResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + env, err := proto.NewEnvelope(proto.TypeRuntimePrepareResult, id, result) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go new file mode 100644 index 000000000..eb7d59f7f --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -0,0 +1,56 @@ +package dispatch_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "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" +) + +// Directory selection is syntactically valid and passes frozen configuration +// admission. Runtime preparation must fail before creating a native Executor. +func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { + for _, mode := range []string{"missing-directory", "invalid-skill"} { + t.Run(mode, func(t *testing.T) { + source := filepath.Join(t.TempDir(), "capability") + if mode == "invalid-skill" { + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("not a portable Skill"), 0600); err != nil { + t.Fatal(err) + } + } + sender := &recSender{} + var calls atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + calls.Add(1) + return nil, errors.New("native factory must not be reached") + }) + installation := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if info, err := os.Stat(installation); err != nil || !info.IsDir() { + t.Fatalf("capability fixture is unavailable: %v", err) + } + request := preparationRequest() + request.Configuration.LocalEnvironment.Capabilities = true + request.Configuration.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "missing-capability", request)); err != nil { + t.Fatalf("valid frozen selection rejected before preparation: %v", err) + } + status := waitPreparationStatus(t, sender, "missing-capability", "failed", "") + if _, err := os.Stat(filepath.Join(installation, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("invalid source published an installed snapshot") + } + if status.ErrorCode != "preparation_failed" || calls.Load() != 0 || r.ActiveRuns() != 0 { + t.Fatalf("failed capability preparation reached native execution: status=%+v calls=%d", status, calls.Load()) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go new file mode 100644 index 000000000..bcdbcda11 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go @@ -0,0 +1,351 @@ +package dispatch + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "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/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/google/uuid" +) + +type capabilitiesTestSender struct{ frames chan proto.Envelope } + +func (s *capabilitiesTestSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.frames <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { + t.Helper() + environment, session := uuid.NewString(), uuid.NewString() + binding, err := localworkspace.New(environment, session, t.TempDir()) + if err != nil { + t.Fatal(err) + } + sender := &capabilitiesTestSender{frames: make(chan proto.Envelope, 64)} + router, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, LocalWorkspace: binding}) + if err != nil { + t.Fatal(err) + } + return router, sender, environment, session +} + +func capabilityEnvelope(t *testing.T, id string, request proto.RuntimePreparePayload) proto.Envelope { + t.Helper() + env, err := proto.NewEnvelope(proto.TypeRuntimePrepare, id, request) + if err != nil { + t.Fatal(err) + } + return env +} + +func capabilityBegin(environment, session string, body []byte) proto.RuntimePreparePayload { + digest := sha256.Sum256(body) + return proto.RuntimePreparePayload{ + Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session, + Skill: &agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, + SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]), + } +} + +func capabilitiesReceipt(t *testing.T, sender *capabilitiesTestSender, id, outcome string) proto.RuntimePrepareResultPayload { + t.Helper() + select { + case env := <-sender.frames: + var result proto.RuntimePrepareResultPayload + if env.Type != proto.TypeRuntimePrepareResult || env.ID != id || env.DecodePayload(&result) != nil || result.Outcome != outcome { + t.Fatalf("unexpected capability receipt: id=%s type=%s result=%+v", env.ID, env.Type, result) + } + return result + case <-time.After(3 * time.Second): + t.Fatal("missing capability receipt") + } + return proto.RuntimePrepareResultPayload{} +} + +func shutdownCapabilitiesRouter(t *testing.T, router *Router) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Fatal(err) + } +} + +func TestRuntimePreparationTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { + for _, mode := range []string{"offset", "digest", "short", "oversized-chunk"} { + t.Run(mode, func(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + defer shutdownCapabilitiesRouter(t, r) + body := bytes.Repeat([]byte("a"), proto.RuntimePrepareChunkBytes+1) + request := capabilityBegin(environment, session, body) + if mode == "digest" { + request.SHA256 = stringsOfZeroDigest() + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "ready") + r.mu.Lock() + owner := r.runtimePreparation + r.mu.Unlock() + duplicate := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, duplicate, request)); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, duplicate, "rejected"); got.ErrorCode != "runtime_preparation_capacity" { + t.Fatal(got) + } + switch mode { + case "offset": + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Offset: 1, Data: []byte("a")})) + case "oversized-chunk": + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Data: body})) + default: + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Data: body[:proto.RuntimePrepareChunkBytes]})); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, id, "received"); got.Offset != proto.RuntimePrepareChunkBytes { + t.Fatal(got) + } + if mode == "digest" { + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Offset: proto.RuntimePrepareChunkBytes, Data: body[proto.RuntimePrepareChunkBytes:]})) + capabilitiesReceipt(t, sender, id, "received") + } + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "commit"})) + } + capabilitiesReceipt(t, sender, id, "rejected") + r.mu.Lock() + defer r.mu.Unlock() + if owner.apply || owner.data != nil || r.runtimePreparation != nil { + t.Fatal("invalid body retained or admitted a mutation") + } + }) + } +} + +func stringsOfZeroDigest() string { return hex.EncodeToString(make([]byte, sha256.Size)) } + +func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + defer shutdownCapabilitiesRouter(t, r) + for _, mode := range []string{"environment", "session", "size", "nil-binding"} { + request := capabilityBegin(environment, session, []byte("abc")) + switch mode { + case "environment": + request.EnvironmentID = uuid.NewString() + case "session": + request.SessionID = uuid.NewString() + case "size": + request.SizeBytes = proto.RuntimePrepareMaxBytes + 1 + case "nil-binding": + r.localWorkspace = nil + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "rejected") + if r.runtimePreparation != nil { + t.Fatal("invalid scope allocated a transfer") + } + } +} + +func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { + for _, mode := range []string{"write", "export", "read", "run", "idle", "executor", "preparation"} { + t.Run(mode, func(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + switch mode { + case "write": + r.workspaceWrite = &workspaceUpload{} + case "export": + r.workspaceExport = &workspaceExport{} + case "read": + r.workspaceReads = map[string]struct{}{"read": {}} + case "run": + r.sessions["run"] = &sessionState{} + case "idle": + r.idle["state"] = map[*sessionState]struct{}{} + case "executor": + r.executors[session] = &executorState{} + case "preparation": + r.preparations["p"] = &preparationState{owns: true} + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { + t.Fatal(got) + } + if r.runtimePreparation != nil { + t.Fatal("busy Runtime admitted capability preparation") + } + r.workspaceWrite = nil + r.workspaceExport = nil + r.workspaceReads = nil + clear(r.sessions) + clear(r.idle) + clear(r.executors) + clear(r.preparations) + shutdownCapabilitiesRouter(t, r) + }) + } +} + +func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "ready") + if err := r.Quiesce(t.Context(), proto.EnvironmentSuspendPayload{EnvironmentID: environment, SuspendID: uuid.NewString()}); !errors.Is(err, ErrRouterBusy) { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("abc")) + write, err := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "proof", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}) + if err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), write); err != nil { + t.Fatal(err) + } + select { + case env := <-sender.frames: + var result proto.WorkspaceWriteResultPayload + if env.Type != proto.TypeWorkspaceWriteResult || env.DecodePayload(&result) != nil || result.Outcome != "rejected" || result.ErrorCode != "resource_unavailable" { + t.Fatal("workspace mutation bypassed capability transfer") + } + case <-time.After(time.Second): + t.Fatal("missing write rejection") + } + r.mu.Lock() + owner := r.runtimePreparation + r.mu.Unlock() + shutdownCapabilitiesRouter(t, r) + if owner.data != nil || r.runtimePreparation != nil { + t.Fatal("disconnect retained uncommitted body") + } +} + +func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + ctx, cancel := context.WithCancel(context.Background()) + id := uuid.NewString() + request := proto.RuntimePreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} + owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + close(owner.ready) + r.runtimePreparation = owner + r.shutdownWG.Add(1) + started, interrupted, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + retained := filepath.Join(t.TempDir(), "installed.json") + go r.runRuntimePreparationTransfer(ctx, owner, func(ctx context.Context, got proto.RuntimePreparePayload, data []byte) error { + if got.Action != "finalize" || len(data) != 0 { + return agentcapabilities.ErrInvalid + } + close(started) + <-ctx.Done() + close(interrupted) + <-release + return os.WriteFile(retained, []byte("retained"), 0400) + }) + <-started + wait, stop := context.WithTimeout(context.Background(), 20*time.Millisecond) + err := r.Shutdown(wait) + stop() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("shutdown discarded running mutation: %v", err) + } + <-interrupted + r.mu.Lock() + owned := r.runtimePreparation == owner + r.mu.Unlock() + if !owned { + t.Fatal("cancel released unsettled capability ownership") + } + close(release) + shutdownCapabilitiesRouter(t, r) + capabilitiesReceipt(t, sender, id, "completed") + if _, err := os.Stat(retained); err != nil { + t.Fatal("shutdown deleted installation result") + } + if r.runtimePreparation != nil { + t.Fatal("confirmed completion retained capacity") + } +} + +func TestRuntimePreparationInitializationReceipts(t *testing.T) { + for _, code := range []int{-1, 0, 1, 255, 256} { + got := runtimePreparationResult(&localworkspace.InitializationFailure{ExitCode: &code}, 0) + if code > 0 && code <= 255 { + if got.Outcome != "failed" || got.ExitCode != code { + t.Fatalf("lost confirmed exit code: %+v", got) + } + } else if got.Outcome != "unknown" { + t.Fatalf("accepted invalid failure receipt: %+v", got) + } + } + if got := runtimePreparationResult(&localworkspace.InitializationFailure{}, 0); got.Outcome != "failed" || got.ExitCode != 0 { + t.Fatalf("lost confirmed generic failure: %+v", got) + } + if got := runtimePreparationResult(errors.Join(&localworkspace.InitializationFailure{}, context.Canceled), 0); got.Outcome != "unknown" { + t.Fatalf("cancellation reported confirmed: %+v", got) + } +} + +func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { + for _, tc := range []struct { + err error + outcome, code string + }{ + {nil, "completed", ""}, + {agentcapabilities.ErrInvalid, "failed", "runtime_preparation_failed"}, + {context.DeadlineExceeded, "unknown", "runtime_preparation_unconfirmed"}, + {errors.Join(agentcapabilities.ErrInvalid, context.Canceled), "unknown", "runtime_preparation_unconfirmed"}, + {errors.New("private native diagnostic"), "unknown", "runtime_preparation_unconfirmed"}, + } { + got := runtimePreparationResult(tc.err, 3) + if got.Outcome != tc.outcome || got.ErrorCode != tc.code || !proto.ValidRuntimePrepareResult(got, "completed", 0, 3) { + t.Fatalf("unsafe result: %+v", got) + } + } + r, sender, environment, session := capabilitiesTestRouter(t) + ctx, cancel := context.WithCancel(context.Background()) + id := uuid.NewString() + request := capabilityBegin(environment, session, []byte("abc")) + owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + close(owner.ready) + r.runtimePreparation = owner + r.shutdownWG.Add(1) + go r.runRuntimePreparationTransfer(ctx, owner, func(context.Context, proto.RuntimePreparePayload, []byte) error { return context.DeadlineExceeded }) + capabilitiesReceipt(t, sender, id, "unknown") + r.mu.Lock() + owned := r.runtimePreparation == owner && owner.uncertain && owner.data == nil + r.mu.Unlock() + if !owned { + t.Fatal("unknown mutation released its ownership") + } + wait, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + if err := r.Shutdown(wait); err == nil { + t.Fatal("shutdown claimed uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/parsar-daemon/internal/dispatch/shutdown.go index a59808ead..15d7f7061 100644 --- a/apps/parsar-daemon/internal/dispatch/shutdown.go +++ b/apps/parsar-daemon/internal/dispatch/shutdown.go @@ -43,6 +43,9 @@ func (r *Router) Shutdown(ctx context.Context) error { } if first { r.closed = true + if r.runtimePreparation != nil { + r.runtimePreparation.cancel() + } for _, states := range r.idle { for state := range states { state.retain = false @@ -57,12 +60,14 @@ func (r *Router) Shutdown(ctx context.Context) error { close(r.shutdownCh) } preparations := r.closePendingPreparationsLocked() + executors := r.closeIdleExecutorsLocked() attempt := &shutdownAttempt{done: make(chan struct{})} r.shutdownAttempt = attempt // Keep the WaitGroup non-zero until all cancellation dispatch is complete. r.shutdownWG.Add(1) r.mu.Unlock() + r.closeIdleExecutors(executors) for _, p := range preparations { go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() } @@ -98,6 +103,9 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC r.shutdownWG.Wait() r.mu.Lock() + if r.runtimePreparation != nil && r.runtimePreparation.uncertain { + attempt.err = errors.Join(attempt.err, errors.New("dispatch: capability preparation remains uncertain")) + } if r.workspaceWrite != nil && r.workspaceWrite.uncertain { attempt.err = errors.Join(attempt.err, errors.New("dispatch: local workspace write remains uncertain")) } @@ -110,6 +118,9 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: preparation %s: %w", p.status.Handle, cause)) } } + for _, owner := range r.executors { + attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s cleanup unconfirmed: %w", owner.id, owner.closeErr)) + } close(attempt.done) r.mu.Unlock() } @@ -139,6 +150,9 @@ func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) e r.mu.Unlock() return ErrRouterClosed } + if r.runtimePreparation != nil { + r.runtimePreparation.cancel() + } victims := make([]sessionCancellation, 0, len(r.sessions)) for _, state := range r.sessions { state.retain = false @@ -155,7 +169,9 @@ func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) e } r.idle = make(map[string]map[*sessionState]struct{}) preparations := r.closePendingPreparationsLocked() + executors := r.closeIdleExecutorsLocked() r.mu.Unlock() + r.closeIdleExecutors(executors) for _, p := range preparations { go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() } diff --git a/apps/parsar-daemon/internal/dispatch/steering.go b/apps/parsar-daemon/internal/dispatch/steering.go index 00265cb91..c6636fa1b 100644 --- a/apps/parsar-daemon/internal/dispatch/steering.go +++ b/apps/parsar-daemon/internal/dispatch/steering.go @@ -98,8 +98,8 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr return &ack } if input.DurableReceipt { - if _, ok := session.(agent.DurableSteerer); !ok || !state.releaseOnCompletion { - ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported release-on-completion run." + 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 } } diff --git a/apps/parsar-daemon/internal/dispatch/suspend.go b/apps/parsar-daemon/internal/dispatch/suspend.go index 924fe237e..e52df4858 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend.go +++ b/apps/parsar-daemon/internal/dispatch/suspend.go @@ -31,7 +31,7 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa r.mu.Unlock() return ErrRouterQuiesced } - if len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { + if r.runtimePreparation != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { r.mu.Unlock() return ErrRouterBusy } @@ -49,6 +49,12 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa } } } + for _, owner := range r.executors { + if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil || owner.environmentID != request.EnvironmentID { + r.mu.Unlock() + return ErrRouterBusy + } + } r.suspension = &request for _, p := range r.preparations { if p.timer != nil { @@ -63,6 +69,12 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa } } } + for _, owner := range r.executors { + owner.idleLease++ + if owner.timer != nil { + owner.timer.Stop() + } + } r.mu.Unlock() err := r.shutdownWG.waitContext(ctx) r.mu.Lock() @@ -89,6 +101,9 @@ func (r *Router) Resume(request proto.EnvironmentSuspendPayload, sender Sender) } r.sender = sender r.suspension = nil + for _, owner := range r.executors { + r.scheduleExecutorIdleLocked(owner) + } for _, states := range r.idle { for state := range states { r.scheduleIdleLocked(state) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go index 49835c073..9af962dd9 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -3,6 +3,8 @@ package dispatch_test import ( "context" "fmt" + "os" + "path/filepath" "testing" "time" @@ -17,9 +19,14 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { 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: 2} + 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) @@ -33,7 +40,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { t.Fatal(got) } if phase == "idle" { - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("start")})) + _ = 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" { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go index f8202a2af..0a3959c9b 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go @@ -1,7 +1,10 @@ package dispatch import ( + "archive/tar" + "bytes" "context" + "io" "os" "path/filepath" "testing" @@ -26,23 +29,25 @@ func (s exportSender) Send(ctx context.Context, env proto.Envelope) error { func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { t.Helper() - workspace, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, "outputs"), 0700); err != nil { t.Fatal(err) } - dir, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + if err := os.WriteFile(filepath.Join(workspace, "outputs", "a"), make([]byte, 131089), 0600); err != nil { t.Fatal(err) } - helper := filepath.Join(dir, "export") - if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { - t.Fatal(err) + if program == "failure" { + f, err := os.Create(filepath.Join(workspace, "outputs", "z")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(201 << 20); err != nil { + t.Fatal(err) + } + f.Close() } environment, session := uuid.NewString(), uuid.NewString() - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_EXPORT_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": "", "OAC_RUNTIME_STAGING": "", "OAC_RUNTIME_NETWORK_ACCESS": ""} { - t.Setenv(key, value) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } @@ -92,7 +97,7 @@ func readExport(t *testing.T, s exportSender) proto.WorkspaceExportResultPayload } func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing.T) { - r, s, request := exporterRouter(t, "head -c 131089 /dev/zero") + r, s, request := exporterRouter(t, "normal") for _, field := range []string{"environment", "handle"} { bad := request if field == "environment" { @@ -108,6 +113,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. id := uuid.NewString() sendExport(t, r, id, request) var offset int64 + var archive bytes.Buffer for { p := readExport(t, s) if p.Offset != offset { @@ -119,11 +125,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. if p.Outcome != "chunk" || len(p.Data) == 0 || len(p.Data) > proto.WorkspaceExportChunkBytes { t.Fatal("invalid chunk") } - for _, b := range p.Data { - if b != 0 { - t.Fatal("wrong byte") - } - } + archive.Write(p.Data) offset += int64(len(p.Data)) select { case <-s.replies: @@ -132,27 +134,43 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. } sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } - if offset != 131089 { - t.Fatal("truncated export", offset) + reader := tar.NewReader(&archive) + header, err := reader.Next() + if err != nil || header.Name != "outputs/a" || header.Size != 131089 { + t.Fatal("invalid archive", header, err) + } + data, err := io.ReadAll(reader) + if err != nil || !bytes.Equal(data, make([]byte, 131089)) { + t.Fatal("truncated artifact", err) + } + if _, err = reader.Next(); err != io.EOF { + t.Fatal("unexpected extra artifact", err) } } func TestWorkspaceExportFailureAfterBytesCannotComplete(t *testing.T) { - r, s, request := exporterRouter(t, "printf abc; exit 1") + r, s, request := exporterRouter(t, "failure") id := uuid.NewString() sendExport(t, r, id, request) - p := readExport(t, s) - if p.Outcome != "chunk" || string(p.Data) != "abc" { - t.Fatal("missing prefix", p) - } - sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: 3}) - if p := readExport(t, s); p.Outcome != "failed" { - t.Fatal("failed process appeared complete", p) + var offset int64 + for { + p := readExport(t, s) + if p.Outcome == "failed" { + if offset == 0 { + t.Fatal("no prefix before failure") + } + break + } + if p.Outcome != "chunk" || p.Offset != offset { + t.Fatal("failed export appeared complete", p) + } + offset += int64(len(p.Data)) + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } } -func TestWorkspaceExportCancelUnblocksProcessAndReleasesCapacity(t *testing.T) { - r, _, request := exporterRouter(t, "exec sleep 30") +func TestWorkspaceExportCancelUnblocksWriterAndReleasesCapacity(t *testing.T) { + r, _, request := exporterRouter(t, "normal") id := uuid.NewString() sendExport(t, r, id, request) sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "cancel"}) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read.go b/apps/parsar-daemon/internal/dispatch/workspace_read.go index 77aeb8b67..0932387d2 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_read.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_read.go @@ -68,6 +68,9 @@ func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (an 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 || diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go index 751cdb807..7226ef20d 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -46,7 +46,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e return ErrRouterClosed } if request.Step == "begin" { - if r.workspaceExport != nil { + if r.workspaceExport != nil || r.runtimePreparation != nil { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } @@ -58,10 +58,16 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e } return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) } - if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 { + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.workspaceReads) != 0 { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } + for _, owner := range r.executors { + if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + } for _, p := range r.preparations { if p.owns { r.mu.Unlock() diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go index ee32da350..b2530eaf8 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -16,31 +16,11 @@ import ( "github.com/google/uuid" ) -func localWriterRouter(t *testing.T, response string) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { +func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { t.Helper() - parent, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - workspace, staging := filepath.Join(parent, "workspace"), filepath.Join(parent, "staging") - for _, p := range []string{workspace, staging} { - if err := os.Mkdir(p, 0700); err != nil { - t.Fatal(err) - } - } - helperRoot, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - helper := filepath.Join(helperRoot, "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\ntouch \"$1/invoked\"\nprintf '%s' '"+response+"'\n"), 0700); err != nil { - t.Fatal(err) - } + workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - for k, v := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": helper, "OAC_RUNTIME_STAGING": staging} { - t.Setenv(k, v) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } @@ -81,7 +61,7 @@ func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) pro } func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) for _, field := range []string{"environment", "session"} { bad := request if field == "environment" { @@ -108,8 +88,8 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { t.Fatal(err) } } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("helper started before commit") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("file created before commit") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { t.Fatal(err) @@ -117,15 +97,15 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { if got := waitWorkspaceWrite(t, sender, id, "completed"); got.SizeBytes != 3 { t.Fatal(got) } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); err != nil { - t.Fatal("helper not called", err) + if data, err := os.ReadFile(filepath.Join(workspace, "file")); err != nil || string(data) != "abc" { + t.Fatal("committed bytes differ", err) } } func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short"} { t.Run(mode, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) id := uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)) chunk := proto.WorkspaceWritePayload{Step: "chunk", Data: []byte("abc")} @@ -140,32 +120,13 @@ func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, chunk)) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})) waitWorkspaceWrite(t, sender, id, "rejected") - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("bad transfer invoked helper") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("bad transfer mutated workspace") } }) } } -func TestLocalUploadUnknownRetainsOwner(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"unknown","error":"write_failed"}`) - id := uuid.NewString() - for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { - t.Fatal(err) - } - } - waitWorkspaceWrite(t, sender, id, "unknown") - next := uuid.NewString() - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)) - if got := waitWorkspaceWrite(t, sender, next, "rejected"); got.ErrorCode != "write_capacity" { - t.Fatal(got) - } - if err := r.Shutdown(t.Context()); err == nil { - t.Fatal("shutdown declared uncertain mutation settled") - } -} - func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { for helperError, reason := range map[string]string{ "destination_directory": proto.WorkspaceWriteReasonDirectory, @@ -173,7 +134,22 @@ func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { "write_failed": "", } { t.Run(helperError, func(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`) + r, sender, request, workspace := localWriterRouter(t) + switch helperError { + case "destination_directory": + if err := os.Mkdir(filepath.Join(workspace, "file"), 0700); err != nil { + t.Fatal(err) + } + case "unsafe_destination": + if err := os.WriteFile(filepath.Join(workspace, "file"), []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + case "write_failed": + if err := os.WriteFile(filepath.Join(workspace, "parent"), nil, 0600); err != nil { + t.Fatal(err) + } + request.Path = "parent/file" + } id := uuid.NewString() for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 928db7fdb..8bceb4a83 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -5,10 +5,13 @@ import ( "os" "path/filepath" "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-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/google/uuid" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. @@ -18,68 +21,45 @@ type Binding struct { allowedDomains []string stateKey string workspace string - helper string - exportHelper string writer *fileWriter + capabilityMu sync.Mutex + capabilityRoot string } -func New(environment, session, workspace, helper string) (*Binding, error) { - for _, id := range []string{environment, session} { - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id { - return nil, errors.New("local workspace requires canonical resource identities") - } - } - for _, name := range []string{workspace, helper} { - if !filepath.IsAbs(name) || filepath.Clean(name) != name || name == "/" || strings.ContainsAny(name, "\x00\r\n\\") { - return nil, errors.New("local workspace requires clean absolute deployment paths") - } - } - root, err := os.Lstat(workspace) - if err != nil || !root.IsDir() || root.Mode()&os.ModeSymlink != 0 { - return nil, errors.New("local workspace root must be an existing directory") - } - program, err := os.Stat(helper) - if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || strings.HasPrefix(helper, workspace+string(filepath.Separator)) { - return nil, errors.New("local workspace helper must be executable outside the workspace") +func New(environment, session, workspace string) (*Binding, error) { + root, err := paths.Root() + if err != nil { + return nil, err } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper}, nil + return newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) +} + +// NewWithCapabilityDirectory freezes paths selected by the Runtime operator. +func NewWithCapabilityDirectory(environment, session, workspace, directory string) (*Binding, error) { + return newNativeBinding(environment, session, workspace, directory) } func Load() (*Binding, error) { - values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE"), os.Getenv("OAC_RUNTIME_DIRECTORY_HELPER")} + values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE")} policy, err := RuntimeNetworkPolicy() if err != nil { return nil, err } network := policy.Access - writeHelper, staging := os.Getenv("OAC_RUNTIME_WRITE_HELPER"), os.Getenv("OAC_RUNTIME_STAGING") - exportHelper := os.Getenv("OAC_RUNTIME_EXPORT_HELPER") - if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" { + capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if strings.Join(values, "") == "" && network == "" && capabilityDirectory == "" { return nil, nil } - b, err := New(values[0], values[1], values[2], values[3]) + if capabilityDirectory == "" { + capabilityDirectory = CapabilityDirectory + } + b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], capabilityDirectory) if err != nil { return nil, err } b.networkAccess = network b.allowedDomains = policy.Hosts() - if exportHelper != "" { - // Reuse the startup executable/root checks; this grants no caller authority. - if _, err := New(values[0], values[1], values[2], exportHelper); err != nil { - return nil, err - } - resolved, err := filepath.EvalSymlinks(exportHelper) - if err != nil || resolved != exportHelper { - return nil, errors.New("workspace exporter must be a canonical executable") - } - b.exportHelper = exportHelper - } - if writeHelper != "" || staging != "" { - if err := b.bindWriter(writeHelper, staging); err != nil { - return nil, err - } - } + return b, nil } @@ -90,7 +70,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.DisableExecutionEnvironment || r.WorkDir != "" || - r.ConversationID != "" || r.WorkspaceAuthoring || !r.StrictResume || !r.ReleaseOnCompletion { + r.ConversationID != "" || r.WorkspaceAuthoring || !r.StrictResume { return r, errors.New("request does not match the dedicated local Environment") } if !r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "" || len(r.LocalEnvironment.AllowedDomains) > 0 { @@ -100,39 +80,25 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } } if !r.WorkspaceReadOnly { - if r.LocalEnvironment.SystemPackages && !r.LocalEnvironment.ToolEnvironment { - return r, errors.New("system packages require initialized tool configuration") + local := *r.LocalEnvironment + if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { + return r, errors.New("request does not match the local workspace selection") } - if r.LocalEnvironment.ToolEnvironment { - if err := VerifyToolEnvironment(r.LocalEnvironment.SystemPackages); err != nil { - return r, err - } + if local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil { + return r, agentcapabilities.ErrInvalid } - local := *r.LocalEnvironment - local.Skills = nil - local.MCP = nil - if local.Capabilities { - manifest, err := LoadCapabilities() - if err != nil { - return r, err - } - local.Skills = manifest.Skills - if len(manifest.MCP) != 0 { - if b.NetworkPolicy().Access != "enabled" { - return r, errors.New("environment MCP requires qualified enabled-network execution") - } - values, err := ReadToolEnvironment() - if err != nil { - return r, err - } - local.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) - if err != nil { - return r, err - } - } + sources := *local.CapabilitySources + present := len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0 + if present != local.Capabilities { + return r, agentcapabilities.ErrInvalid } + local.Skills, local.MCP, local.CapabilityRoot = nil, nil, "" r.LocalEnvironment = &local r.WorkDir = b.workspace } return r, nil } + +func (b *Binding) Matches(environment, session string) bool { + return b != nil && b.environment == environment && b.stateKey == "agents-api-"+session +} diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 98973300b..c954e6e0c 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -1,31 +1,31 @@ package localworkspace import ( - "errors" - "io/fs" "os" "path/filepath" "testing" - "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/google/uuid" ) func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { t.Helper() - root := t.TempDir() - helper := filepath.Join(t.TempDir(), "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + private := t.TempDir() + if err := os.Chmod(private, 0700); err != nil { t.Fatal(err) } + t.Setenv("OAC_RUNTIME_HOME", private) + root := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - b, err := New(environment, session, root, helper) + b, err := New(environment, session, root) if err != nil { t.Fatal(err) } b.networkAccess = "disabled" - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled"}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} + b.capabilityRoot = t.TempDir() + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} } func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { @@ -61,16 +61,11 @@ func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { } } -func TestLocalHelperCannotInheritCredentials(t *testing.T) { +func TestDirectoryValidatesRelativePaths(t *testing.T) { b, _ := testBinding(t) - t.Setenv("OAC_TEST_PRIVATE_CREDENTIAL", "synthetic-secret") - script := "#!/bin/sh\n[ -z \"$OAC_TEST_PRIVATE_CREDENTIAL\" ] || exit 13\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n" - if err := os.WriteFile(b.helper, []byte(script), 0o700); err != nil { - t.Fatal(err) - } got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) if err != nil || got.Entries == nil || len(got.Entries) != 0 || got.Truncated { - t.Fatalf("read-only helper: %+v %v", got, err) + t.Fatalf("empty directory: %+v %v", got, err) } for _, path := range []string{"/etc", "..", "a/../b", "a//b", ".", "a\\b"} { if _, err := b.ListWorkspaceDirectory(t.Context(), path, 2); err == nil { @@ -79,31 +74,22 @@ func TestLocalHelperCannotInheritCredentials(t *testing.T) { } } -func TestDirectoryClassifiesNativeErrors(t *testing.T) { - for code, want := range map[string]error{ - "not_directory": agent.ErrWorkspaceNotDirectory, - "not_found": fs.ErrNotExist, - "permission_denied": fs.ErrPermission, - "invalid_path": agent.ErrWorkspaceReadInvalid, - "native_error": agent.ErrWorkspaceReadUncertain, - } { - if _, err := decodeDirectory([]byte(`{"version":1,"error":"`+code+`"}`), 2); !errors.Is(err, want) { - t.Fatal("native error classification changed", code, err) - } +func TestBindingAllowsRetainedExecutor(t *testing.T) { + b, req := testBinding(t) + req.ReleaseOnCompletion = false + if _, err := b.Configure(req); err != nil { + t.Fatal(err) } } -func TestDirectoryRejectsMalformedOrIncompleteResponses(t *testing.T) { - for _, frame := range []string{ - `{"version":2,"directory":{"entries":[],"truncated":false}}`, - `{"version":1,"directory":{"entries":[]}}`, - `{"version":1,"directory":{"entries":null,"truncated":false}}`, - `{"version":1,"directory":{"entries":[{"name":"../secret","kind":"file","size_bytes":1}],"truncated":false}}`, - `{"version":1,"error":"unknown"}`, - `{"version":1,"directory":{"entries":[],"truncated":false}} {}`, - } { - if _, err := decodeDirectory([]byte(frame), 2); err == nil { - t.Fatal("unconfirmed response accepted", frame) +func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { + b, _ := testBinding(t) + for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, directory); err != nil { + t.Fatal(err) } } + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "relative"); err == nil { + t.Fatal("relative installation path accepted") + } } diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go new file mode 100644 index 000000000..13605e761 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -0,0 +1,198 @@ +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() + 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": + _, 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/parsar-daemon/internal/localworkspace/capabilities_test.go index be69fcb28..2713ae93e 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go @@ -11,7 +11,8 @@ import ( func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { binding, request := testBinding(t) - request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller"}} + request.LocalEnvironment.CapabilityRoot = "/caller/installation" + request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller", InstallationRoot: "/caller/installation"}} secret := "private-mcp-marker" request.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "caller/private", BearerToken: &secret}} raw, err := json.Marshal(request.LocalEnvironment) @@ -19,7 +20,7 @@ func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { t.Fatal("Runtime paths crossed the public daemon descriptor", err) } configured, err := binding.Configure(request) - if err != nil || len(configured.LocalEnvironment.Skills) != 0 || len(configured.LocalEnvironment.MCP) != 0 { + if err != nil || len(configured.LocalEnvironment.Skills) != 0 || len(configured.LocalEnvironment.MCP) != 0 || configured.LocalEnvironment.CapabilityRoot != "" { t.Fatal("execution accepted caller-supplied Skill paths", err) } request.LocalEnvironment.Capabilities = true diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go new file mode 100644 index 000000000..fbfc83904 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -0,0 +1,180 @@ +package localworkspace + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func writeSourceSkill(t *testing.T, directory, text string) { + t.Helper() + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "SKILL.md"), []byte("---\nname: local-proof\ndescription: Local preparation proof\n---\n"+text), 0600); err != nil { + t.Fatal(err) + } +} + +func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { + b, req := testBinding(t) + source := t.TempDir() + writeSourceSkill(t, source, "first") + req.LocalEnvironment.Capabilities = true + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("binding installed before asynchronous admission") + } + first, err := b.Prepare(t.Context(), configured) + if err != nil || len(first.LocalEnvironment.Skills) != 1 { + t.Fatalf("first preparation: %v", err) + } + writeSourceSkill(t, source, "second") + reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace) + if err != nil { + t.Fatal(err) + } + reconnect.networkAccess = b.networkAccess + reconnect.capabilityRoot = b.capabilityRoot + again, err := reconnect.Prepare(t.Context(), configured) + if err != nil || len(again.LocalEnvironment.Skills) != 1 { + t.Fatalf("reconnection: %v", err) + } + frozen, err := os.ReadFile(filepath.Join(b.capabilityRoot, again.LocalEnvironment.Skills[0].RelativeRoot, "SKILL.md")) + if err != nil || string(frozen[len(frozen)-5:]) != "first" { + t.Fatal("reconnection recaptured source", err) + } + next, nextReq := testBinding(t) + nextReq.LocalEnvironment.Capabilities = true + nextReq.LocalEnvironment.CapabilitySources = req.LocalEnvironment.CapabilitySources + nextConfigured, err := next.Configure(nextReq) + if err != nil { + t.Fatal(err) + } + if _, err = next.Prepare(t.Context(), nextConfigured); err != nil { + t.Fatal(err) + } + fresh, err := os.ReadFile(filepath.Join(next.capabilityRoot, "directories/0/SKILL.md")) + if err != nil || string(fresh[len(fresh)-6:]) != "second" { + t.Fatal("new Session did not capture new source", err) + } + // Reusing a snapshot under another identity or selection cannot start native work. + reconnect.stateKey = "agents-api-" + uuid.NewString() + changed := configured + changed.AgentStateKey = reconnect.stateKey + if _, err = reconnect.Prepare(t.Context(), changed); err == nil { + t.Fatal("foreign snapshot accepted") + } + changed = configured + local := *configured.LocalEnvironment + local.CapabilitySources = &agentcapabilities.Input{} + changed.LocalEnvironment = &local + if _, err = b.Prepare(t.Context(), changed); err == nil { + t.Fatal("changed selection accepted") + } +} + +func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { + b, req := testBinding(t) + private := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", private) + writeSourceSkill(t, private, "private") + held, err := b.resolveCapabilityDirectory(private) + if err != nil { + t.Fatal("operator source rejected", err) + } + held.Close() + if held, err = b.resolveCapabilityDirectory(b.capabilityRoot); err == nil { + held.Close() + t.Fatal("recursive snapshot source accepted") + } + source := filepath.Join(b.workspace, "selected") + writeSourceSkill(t, source, "valid") + root, err := b.resolveCapabilityDirectory("/workspace/selected") + if err != nil { + t.Fatal("logical workspace source rejected", err) + } + root.Close() + req.LocalEnvironment.Capabilities = true + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source, t.TempDir()}} + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("invalid second source accepted") + } + if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("partial snapshot ready") + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("partial snapshot silently replayed") + } +} + +func TestPreparationEmptySelectionAndCancellation(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err = b.Prepare(ctx, configured); err == nil { + t.Fatal("cancelled preparation started") + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + read := proto.PromptRequestPayload{WorkspaceReadOnly: true} + if _, err = b.Prepare(t.Context(), read); err != nil { + t.Fatal("Files required capability installation", err) + } +} + +func TestRuntimePreparationRejectsMissingRequiredToolEnvironment(t *testing.T) { + b, req := testBinding(t) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") + req.LocalEnvironment.ToolEnvironment = true + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("missing required tool environment admitted") + } + directory, err := InitializationDirectory() + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "tool-env.json"), []byte(`{"READY":"yes"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal("prepared tool environment rejected", err) + } + if err = os.Remove(filepath.Join(directory, "tool-env.json")); err != nil { + t.Fatal(err) + } + configured.LocalEnvironment.ToolEnvironment = false + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal("optional tool environment became mandatory", err) + } +} + +func TestRuntimePreparationRejectsMissingExplicitToolEnvironment(t *testing.T) { + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) + if _, err := ReadOptionalToolEnvironment(); err == nil { + t.Fatal("missing explicitly configured tool environment ignored") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/parsar-daemon/internal/localworkspace/directory.go index b747c3821..3679a60c4 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory.go +++ b/apps/parsar-daemon/internal/localworkspace/directory.go @@ -1,15 +1,9 @@ package localworkspace import ( - "bytes" "context" - "encoding/json" - "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -19,62 +13,5 @@ func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid } - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - cmd := exec.CommandContext(operation, b.helper, b.workspace, path, strconv.Itoa(limit)) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - pipe, err := cmd.StdoutPipe() - if err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - if err := cmd.Start(); err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - const maxOutput = 4 << 20 - data, err := io.ReadAll(io.LimitReader(pipe, maxOutput+1)) - if err != nil || len(data) > maxOutput { - _ = cmd.Process.Kill() - } - waitErr := cmd.Wait() - if err != nil || waitErr != nil || len(data) > maxOutput { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - return decodeDirectory(data, limit) -} - -func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, error) { - var wire struct { - Version int `json:"version"` - Error *string `json:"error"` - Directory *proto.WorkspaceDirectoryResult `json:"directory"` - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 || (wire.Error == nil) == (wire.Directory == nil) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - if wire.Error != nil { - err := agent.ErrWorkspaceReadUncertain - switch *wire.Error { - case "not_found": - err = fs.ErrNotExist - case "permission_denied": - err = fs.ErrPermission - case "invalid_path": - err = agent.ErrWorkspaceReadInvalid - case proto.WorkspaceReadNotDirectory: - err = agent.ErrWorkspaceNotDirectory - } - return agent.WorkspaceDirectoryResult{}, err - } - if !proto.ValidWorkspaceDirectory(wire.Directory, limit) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - result := agent.WorkspaceDirectoryResult{Entries: make([]agent.WorkspaceDirectoryEntry, 0, len(wire.Directory.Entries)), Truncated: wire.Directory.Truncated} - for _, e := range wire.Directory.Entries { - result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: e.Name, Kind: e.Kind, SizeBytes: e.SizeBytes}) - } - return result, nil + return b.listNativeDirectory(ctx, path, limit) } diff --git a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go index efc2f7bf5..142c69062 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go +++ b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go @@ -2,61 +2,55 @@ package localworkspace import ( "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/google/uuid" ) -func TestNativeLocalDirectoryConfinement(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_DIRECTORY_HELPER") - if helper == "" { - t.Skip("actual pinned directory helper required") - } - root, outside := t.TempDir(), t.TempDir() - if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil { +func TestNativeDirectoryAPI(t *testing.T) { + b := nativeFileBinding(t) + if err := os.Mkdir(filepath.Join(b.workspace, "nested"), 0700); err != nil { t.Fatal(err) } - for _, path := range []string{filepath.Join(root, "nested", "data.bin"), filepath.Join(outside, "secret")} { - if err := os.WriteFile(path, []byte{0, 1, 255, 17}, 0o600); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { - t.Fatal(err) - } - b, err := New(uuid.NewString(), uuid.NewString(), root, helper) - if err != nil { + if err := os.WriteFile(filepath.Join(b.workspace, "nested", "data.bin"), []byte{0, 1, 255, 17}, 0600); err != nil { t.Fatal(err) } got, err := b.ListWorkspaceDirectory(t.Context(), "nested", 10) if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 { - t.Fatalf("native file metadata: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - // A link, a regular file and a missing path are not listable directories; - // the external link target is never listed. - for _, path := range []string{"escape", "escape/secret", "file.txt", "missing", "missing/deeper"} { + for _, path := range []string{"missing", "nested/data.bin"} { if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) { - t.Fatal("native helper classified a non-directory path differently", path, err) + t.Fatal(path, err) } } + if err := os.WriteFile(filepath.Join(b.workspace, "second"), nil, 0600); err != nil { + t.Fatal(err) + } got, err = b.ListWorkspaceDirectory(t.Context(), "", 1) if err != nil || !got.Truncated || len(got.Entries) != 1 { - t.Fatalf("native directory bound: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.Rename(root, root+"-original"); err != nil { +} + +func TestNativeFileReadPrefixAndEntryKinds(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "nested/file", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.RemoveAll(root + "-original") }) - if err := os.Symlink(outside, root); err != nil { + got, err := b.ReadWorkspaceFile(t.Context(), "nested/file", 2) + if err != nil || !got.Truncated || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal(got, err) + } + got, err = b.ReadWorkspaceFile(t.Context(), "nested/file", 3) + if err != nil || got.Truncated || len(got.Data) != 3 { + t.Fatal(got, err) + } + if _, err = b.ReadWorkspaceFile(t.Context(), "nested", 3); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatal(err) } - if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatal("native helper followed a replaced root or hid it as an empty directory", err) + if _, err = b.ReadWorkspaceFile(t.Context(), "missing", 3); !os.IsNotExist(err) { + t.Fatal(err) } } diff --git a/apps/parsar-daemon/internal/localworkspace/export.go b/apps/parsar-daemon/internal/localworkspace/export.go index 104750dee..015e020ab 100644 --- a/apps/parsar-daemon/internal/localworkspace/export.go +++ b/apps/parsar-daemon/internal/localworkspace/export.go @@ -3,71 +3,16 @@ package localworkspace import ( "context" "errors" - "io" - "os" - "os/exec" - "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "io" ) -func (b *Binding) CanExport() bool { return b != nil && b.exportHelper != "" } - -// ExportOutputs streams only from the deployment-owned root; successful exit is mandatory. +func (b *Binding) CanExport() bool { return b != nil && b.workspace != "" } func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { if !b.CanExport() || output == nil { return errors.New("workspace export unavailable") } - cmd := exec.CommandContext(ctx, b.exportHelper, b.workspace) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - stdout, childOutput, err := os.Pipe() - if err != nil { - return err - } - defer stdout.Close() - defer childOutput.Close() - cmd.Stdout = childOutput - if err := cmd.Start(); err != nil { - return err - } - _ = childOutput.Close() - stop := context.AfterFunc(ctx, func() { _ = stdout.Close() }) - defer stop() - exited := make(chan struct{}) - settled := make(chan error, 1) - go func() { - err := cmd.Wait() - // Bound a pending read when a descendant inherits stdout after helper exit. - _ = stdout.SetReadDeadline(time.Now().Add(time.Second)) - close(exited) - settled <- err - }() - // Own the pipe so exec.Wait cannot close buffered output during transport - // backpressure. Only actual pipe reads have a post-exit cleanup deadline. - _, copyErr := io.Copy(&exportWriter{output: output}, exportReader{stdout, exited}) - if copyErr != nil { - _ = cmd.Process.Kill() - } - return errors.Join(copyErr, <-settled) -} - -type exportReader struct { - pipe *os.File - exited <-chan struct{} -} - -func (r exportReader) Read(data []byte) (int, error) { - select { - case <-r.exited: - // Consumer delays do not consume the inherited-pipe cleanup allowance. - if err := r.pipe.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - return 0, err - } - default: - } - return r.pipe.Read(data) + return b.exportNativeOutputs(ctx, &exportWriter{output: output}) } type exportWriter struct { @@ -76,6 +21,9 @@ type exportWriter struct { } func (w *exportWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { return 0, errors.New("workspace export exceeds bound") } diff --git a/apps/parsar-daemon/internal/localworkspace/export_test.go b/apps/parsar-daemon/internal/localworkspace/export_test.go index df17b348e..f396f9f2d 100644 --- a/apps/parsar-daemon/internal/localworkspace/export_test.go +++ b/apps/parsar-daemon/internal/localworkspace/export_test.go @@ -1,111 +1,100 @@ package localworkspace import ( + "archive/tar" "bytes" "context" "errors" "io" "os" "path/filepath" - "strconv" - "strings" "testing" "time" ) -func exportBinding(t *testing.T, program string) *Binding { +func outputBinding(t *testing.T) *Binding { t.Helper() - b, _ := testBinding(t) - b.exportHelper = b.helper - if err := os.WriteFile(b.helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/nested/proof.bin", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - return b -} - -type heldExportWriter struct { - bytes.Buffer - entered chan struct{} - release chan struct{} -} - -func (w *heldExportWriter) Write(p []byte) (int, error) { - if w.entered != nil { - close(w.entered) - w.entered = nil - <-w.release - } - return w.Buffer.Write(p) -} - -func TestExportOutputsAllowsConsumerBackpressureAfterHelperExit(t *testing.T) { - b := exportBinding(t, "head -c 3072 /dev/zero") - entered, release := make(chan struct{}), make(chan struct{}) - w := &heldExportWriter{entered: entered, release: release} - done := make(chan error, 1) - go func() { done <- b.ExportOutputs(t.Context(), w) }() - select { - case <-entered: - case <-time.After(3 * time.Second): - close(release) - t.Fatal("helper produced no output") - } - // The helper can exit while the pull transport is waiting for its consumer. - time.Sleep(1500 * time.Millisecond) - close(release) - if err := <-done; err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/empty", nil); err != nil { t.Fatal(err) } - if !bytes.Equal(w.Bytes(), make([]byte, 3072)) { - t.Fatalf("incomplete output: %d bytes", w.Len()) - } + return b } - -func TestExportOutputsRequiresSuccessfulHelperExit(t *testing.T) { - b := exportBinding(t, "printf complete-prefix; exit 7") +func TestNativeOutputArchive(t *testing.T) { + b := outputBinding(t) var output bytes.Buffer - if err := b.ExportOutputs(t.Context(), &output); err == nil || output.String() != "complete-prefix" { - t.Fatalf("failed helper accepted: output=%q error=%v", output.String(), err) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) } -} - -func TestExportOutputsBoundsInheritedPipeAfterHelperExit(t *testing.T) { - pidFile := filepath.Join(t.TempDir(), "descendant.pid") - b := exportBinding(t, "sleep 30 &\necho $! > '"+pidFile+"'\nprintf prefix") - t.Cleanup(func() { - data, err := os.ReadFile(pidFile) + reader := tar.NewReader(&output) + got := map[string][]byte{} + for { + header, err := reader.Next() + if err == io.EOF { + break + } if err != nil { - return + t.Fatal(err) } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err == nil { - if process, err := os.FindProcess(pid); err == nil { - _ = process.Kill() - } + if header.Typeflag != tar.TypeReg { + t.Fatal(header) } - }) - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + got[header.Name] = raw + } + if len(got) != 2 || !bytes.Equal(got["outputs/nested/proof.bin"], []byte{0, 255, 17}) { + t.Fatal(got) + } +} +func TestNativeExportMissingOutputsAndBound(t *testing.T) { + b := nativeFileBinding(t) var output bytes.Buffer - err := b.ExportOutputs(ctx, &output) - if !errors.Is(err, os.ErrDeadlineExceeded) || ctx.Err() != nil || output.String() != "prefix" { - t.Fatalf("inherited pipe not bounded: output=%q error=%v context=%v", output.String(), err, ctx.Err()) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) + } + if _, err := tar.NewReader(&output).Next(); err != io.EOF { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "outputs"), 0700); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(b.workspace, "outputs", "large")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(artifactFileBytes + 1); err != nil { + t.Fatal(err) + } + f.Close() + if err = b.ExportOutputs(t.Context(), io.Discard); err == nil { + t.Fatal("oversize artifact accepted") } } -func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") +type failedExportWriter struct{ err error } + +func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +func TestNativeExportCancellationAndConsumerFailure(t *testing.T) { + b := outputBinding(t) + want := errors.New("consumer rejected") + if err := b.ExportOutputs(t.Context(), failedExportWriter{want}); !errors.Is(err, want) { + t.Fatal(err) + } ctx, cancel := context.WithCancel(t.Context()) - defer cancel() reader, writer := io.Pipe() defer reader.Close() - // The dispatch owner closes the consumer on cancellation, releasing Write. stop := context.AfterFunc(ctx, func() { _ = reader.CloseWithError(ctx.Err()) }) defer stop() done := make(chan error, 1) go func() { err := b.ExportOutputs(ctx, writer); _ = writer.CloseWithError(err); done <- err }() - got := make([]byte, 1) - if _, err := reader.Read(got); err != nil { + var prefix [1]byte + if _, err := reader.Read(prefix[:]); err != nil { t.Fatal(err) } cancel() @@ -114,21 +103,35 @@ func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { if err == nil { t.Fatal("cancelled export succeeded") } - case <-time.After(3 * time.Second): - t.Fatal("cancellation did not settle exporter") + case <-time.After(time.Second): + t.Fatal("export did not settle") } } -type failedExportWriter struct{ err error } - -func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +type changingOutput struct { + bytes.Buffer + change func() +} -func TestExportOutputsConsumerFailureStopsHelper(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") - want := errors.New("consumer rejected output") - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err := b.ExportOutputs(ctx, failedExportWriter{want}); !errors.Is(err, want) || ctx.Err() != nil { - t.Fatalf("consumer failure did not stop helper: %v context=%v", err, ctx.Err()) +func (w *changingOutput) Write(data []byte) (int, error) { + if w.change != nil { + change := w.change + w.change = nil + change() + } + return w.Buffer.Write(data) +} +func TestNativeExportRejectsChangedFile(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/file", []byte("before")); err != nil { + t.Fatal(err) + } + w := &changingOutput{change: func() { + if err := os.WriteFile(filepath.Join(b.workspace, "outputs", "file"), []byte("changed-length"), 0600); err != nil { + t.Error(err) + } + }} + if err := b.ExportOutputs(t.Context(), w); err == nil { + t.Fatal("changed artifact accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index 11c8a69c2..d147d67b6 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -5,63 +5,86 @@ import ( "errors" "os" "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) -// ReadToolEnvironment selects the immutable user configuration. It never reads -// process environment or introduces live-execution prerequisites for Files. +// InitializationDirectory resolves the operator's local resource layout. The +// protocol and preparation operations do not distinguish Environment sources. +func InitializationDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "initialization") +} +func PackageDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_PACKAGE_DIRECTORY", "packages") +} +func initializationPath(setting, name string) (string, error) { + if path := os.Getenv(setting); path != "" { + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime initialization directory") + } + return path, nil + } + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, name), nil +} + +// ReadToolEnvironment reads bounded explicit configuration, never ambient secrets. func ReadToolEnvironment() (map[string]string, error) { - info, err := os.Lstat(ToolEnvironmentJSON) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1<<20 { - return nil, errors.New("initialized user environment unavailable") + path, err := toolEnvironmentPath() + if err != nil { + return nil, err } - body, err := os.ReadFile(ToolEnvironmentJSON) + body, err := runtimefs.ReadPrivatePath(path, 1<<20) var values map[string]string - if err != nil || json.Unmarshal(body, &values) != nil || values == nil { + if err != nil || json.Unmarshal(body, &values) != nil || values == nil || !validToolEnvironment(values) { return nil, errors.New("initialized user environment unavailable") } return values, nil } - -// These paths belong to the packaged Runtime, not a harness or public template. -const ( - InitializationDirectory = "/environment/initialization" - ToolEnvironmentShell = InitializationDirectory + "/tool-env.sh" - ToolEnvironmentJSON = InitializationDirectory + "/tool-env.json" - PackageDirectory = "/environment/packages" - SystemPackageDirectory = PackageDirectory + "/system" - SystemPackageReceipt = InitializationDirectory + "/system-root.json" - SystemToolLauncher = "/usr/local/bin/oac-tool-root" -) - -// VerifyToolEnvironment is required only for execution consuming initialized -// tool configuration. It never makes Files reads depend on execution setup. -func VerifyToolEnvironment(systemPackages bool) error { - paths := []string{InitializationDirectory, PackageDirectory, ToolEnvironmentShell, ToolEnvironmentJSON} - if systemPackages { - paths = append(paths, SystemPackageDirectory, SystemPackageReceipt, SystemToolLauncher) - } - for _, path := range paths { - actual, err := filepath.EvalSymlinks(path) - info, statErr := os.Lstat(path) - if err != nil || statErr != nil || actual != path { - return errors.New("initialized tool configuration unavailable") - } - if path == InitializationDirectory || path == PackageDirectory || path == SystemPackageDirectory { - if !info.IsDir() { - return errors.New("initialized tool directory unavailable") +func validToolEnvironment(values map[string]string) bool { + seen := map[string]bool{} + for key, value := range values { + if runtime.GOOS == "windows" { + folded := strings.ToUpper(key) + if seen[folded] { + return false } - } else if !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1024*1024 { - return errors.New("initialized tool configuration is not immutable") + seen[folded] = true } - } - if systemPackages { - raw, err := os.ReadFile(SystemPackageReceipt) - var receipt struct { - Version int `json:"version"` + if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { + return false } - if err != nil || json.Unmarshal(raw, &receipt) != nil || receipt.Version != 1 { - return errors.New("installed system tools unavailable") + } + return true +} + +// ReadOptionalToolEnvironment permits a Runtime without explicit user variables. +func ReadOptionalToolEnvironment() (map[string]string, error) { + path, err := toolEnvironmentPath() + if err != nil { + return nil, err + } + if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) && os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE") == "" { + return map[string]string{}, nil + } + return ReadToolEnvironment() +} +func toolEnvironmentPath() (string, error) { + if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime tool environment file") } + return path, nil + } + directory, err := InitializationDirectory() + if err != nil { + return "", err } - return nil + return filepath.Join(directory, "tool-env.json"), nil } diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index 312ee5e4d..38040e2d5 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -2,18 +2,20 @@ package localworkspace import ( "errors" + "os" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" - -// MCPStdioCommand contains only installed identities. The server's executable, -// arguments and selected user variables are resolved after entering isolation. +// MCPStdioCommand resolves the common installed manifest in the daemon. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { - return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.PackageRoot, server.Server.Name} + 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) { diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go index a7d85cef4..77293a5e0 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp_test.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp_test.go @@ -1,6 +1,7 @@ package localworkspace import ( + "os" "slices" "testing" @@ -28,11 +29,15 @@ func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { } func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { - server := proto.EnvironmentMCP{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + 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) - if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "plugins/0", "package_tool"}) { + 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/parsar-daemon/internal/localworkspace/native_binding.go new file mode 100644 index 000000000..bf39d35b5 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_binding.go @@ -0,0 +1,34 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/google/uuid" +) + +func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { + for _, value := range []string{environment, session} { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil || id.String() != value { + return nil, errors.New("local workspace requires canonical resource identities") + } + } + for _, path := range []string{workspace, capabilities} { + if runtimefs.ValidateLocalPath(path) != nil || filepath.Dir(path) == path { + return nil, errors.New("local workspace requires absolute installation paths") + } + } + info, err := os.Stat(workspace) + if err != nil || !info.IsDir() { + return nil, errors.New("local workspace root must be an existing directory") + } + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil +} + +// ReadToolEnvironment reads explicit initialization values for this installation. +func (b *Binding) ReadToolEnvironment() (map[string]string, error) { + return ReadOptionalToolEnvironment() +} diff --git a/apps/parsar-daemon/internal/localworkspace/native_files.go b/apps/parsar-daemon/internal/localworkspace/native_files.go new file mode 100644 index 000000000..c40bf1e5d --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_files.go @@ -0,0 +1,329 @@ +package localworkspace + +import ( + "archive/tar" + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Logical API paths stay slash-separated on every host. os.Root anchors API +// file operations to the selected workspace; it does not constrain native tools. +func nativeAPIPath(path string) (string, error) { + if path == "" { + return ".", nil + } + if path == "." || len(path) > 4096 || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + return "", fs.ErrInvalid + } + return filepath.Localize(path) +} + +func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { + result := agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{}} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + if local != "." { + partial := "" + for _, part := range strings.Split(path, "/") { + partial = filepath.Join(partial, part) + info, err := root.Lstat(partial) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil || !info.IsDir() { + return result, agent.ErrWorkspaceNotDirectory + } + } + } + dir, err := root.Open(local) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil { + return result, agent.ErrWorkspaceNotDirectory + } + defer dir.Close() + entries, err := dir.ReadDir(limit + 1) + if err != nil && err != io.EOF { + return result, agent.ErrWorkspaceNotDirectory + } + if ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + result.Truncated = len(entries) > limit + if result.Truncated { + entries = entries[:limit] + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + wire := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: result.Truncated} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} + switch { + case info.Mode().IsRegular(): + item.Kind = "file" + size := info.Size() + item.SizeBytes = &size + case info.IsDir(): + item.Kind = "directory" + case info.Mode()&os.ModeSymlink != 0: + item.Kind = "symlink" + } + wire.Entries = append(wire.Entries, item) + } + if !proto.ValidWorkspaceDirectory(wire, limit) { + return result, agent.ErrWorkspaceReadInvalid + } + for _, item := range wire.Entries { + result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) + } + return result, nil +} + +func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (agent.WorkspaceWriteResult, error) { + result := agent.WorkspaceWriteResult{} + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceWriteInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceWriteUnavailable + } + defer root.Close() + if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + temporary := ".oac-write-" + uuid.NewString() + file, err := root.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + defer root.Remove(temporary) + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return result, agent.ErrWorkspaceWriteRejected + } + // Link publishes complete bytes without replacing an existing destination. + if err = root.Link(temporary, local); err != nil { + if info, e := root.Lstat(local); e == nil { + if info.IsDir() { + return result, agent.ErrWorkspaceWriteDirectory + } + return result, agent.ErrWorkspaceWriteUnsafe + } + return result, agent.ErrWorkspaceWriteRejected + } + return agent.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil +} + +const artifactFileBytes int64 = 200 << 20 +const artifactBatchBytes int64 = 500 << 20 +const artifactEntries = 4096 + +type nativeExport struct { + ctx context.Context + root *os.Root + archive *tar.Writer + entries int + bytes int64 +} + +func (b *Binding) exportNativeOutputs(ctx context.Context, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return err + } + defer root.Close() + archive := tar.NewWriter(output) + info, err := root.Lstat("outputs") + if errors.Is(err, fs.ErrNotExist) { + return archive.Close() + } + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("workspace outputs is not a directory") + } + exporter := nativeExport{ctx: ctx, root: root, archive: archive} + if err = exporter.walk("outputs", 0); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + return archive.Close() +} +func (x *nativeExport) walk(path string, depth int) error { + if err := x.ctx.Err(); err != nil { + return err + } + if depth > 64 || len(path) > 4096 { + return errors.New("workspace export exceeds traversal bound") + } + dir, err := x.root.Open(path) + if err != nil { + return err + } + entries, err := dir.ReadDir(artifactEntries + 1) + _ = dir.Close() + if err != nil && err != io.EOF { + return err + } + x.entries += len(entries) + if x.entries > artifactEntries { + return errors.New("workspace export exceeds entry bound") + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := path + "/" + entry.Name() + if _, err := nativeAPIPath(name); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + switch { + case info.Mode()&os.ModeSymlink != 0: + continue + case info.IsDir(): + if err = x.walk(name, depth+1); err != nil { + return err + } + case info.Mode().IsRegular(): + if err = x.append(name); err != nil { + return err + } + default: + return errors.New("workspace output is not a regular file") + } + } + return nil +} +func (x *nativeExport) append(path string) error { + if err := x.ctx.Err(); err != nil { + return err + } + file, err := x.root.Open(path) + if err != nil { + return err + } + defer file.Close() + before, err := file.Stat() + if err != nil { + return err + } + size := before.Size() + if !before.Mode().IsRegular() || size < 0 || size > artifactFileBytes || size > artifactBatchBytes-x.bytes { + return errors.New("workspace export exceeds file bound") + } + x.bytes += size + if err = x.archive.WriteHeader(&tar.Header{Name: path, Typeflag: tar.TypeReg, Mode: 0600, Size: size, Format: tar.FormatPAX}); err != nil { + return err + } + if _, err = io.CopyN(x.archive, nativeContextReader{ctx: x.ctx, reader: file}, size); err != nil { + return err + } + after, err := file.Stat() + if err != nil { + return err + } + if after.Size() != size || !after.ModTime().Equal(before.ModTime()) { + return errors.New("workspace output changed during export") + } + return nil +} + +type nativeContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r nativeContextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(data) +} + +// ReadWorkspaceFile returns the existing protocol's bounded prefix; the wire +// has no offset field. Non-regular entries cannot become unbounded streams. +func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + result := agent.WorkspaceReadResult{} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + if path == "" || limit < 1 || limit > proto.WorkspaceReadMaxBytes { + return result, agent.ErrWorkspaceReadInvalid + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + info, err := root.Lstat(local) + if err != nil { + return result, err + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + file, err := root.Open(local) + if err != nil { + return result, err + } + defer file.Close() + info, err = file.Stat() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + raw, err := io.ReadAll(io.LimitReader(nativeContextReader{ctx: ctx, reader: file}, int64(limit)+1)) + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + result.Truncated = len(raw) > limit + if result.Truncated { + raw = raw[:limit] + } + result.Data = raw + return result, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/package_command.go b/apps/parsar-daemon/internal/localworkspace/package_command.go new file mode 100644 index 000000000..9ae5f053c --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/package_command.go @@ -0,0 +1,45 @@ +package localworkspace + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// ResolvePackageManagerCommand runs Windows npm/npx shims through their installed +// JavaScript entrypoints. It does not pass arguments through an extra shell. +func ResolvePackageManagerCommand(command string, args []string) (string, []string, error) { + if runtime.GOOS != "windows" { + return command, args, nil + } + name := strings.ToLower(filepath.Base(command)) + switch name { + case "npm", "npm.cmd", "npx", "npx.cmd": + default: + return command, args, nil + } + shim, err := exec.LookPath(command) + if err != nil { + return "", nil, errors.New("npm or npx launcher unavailable") + } + if !strings.EqualFold(filepath.Ext(shim), ".cmd") { + return shim, args, nil + } + name = strings.TrimSuffix(name, ".cmd") + directory := filepath.Dir(shim) + cli := filepath.Join(directory, "node_modules", "npm", "bin", name+"-cli.js") + if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { + return "", nil, errors.New("npm or npx CLI unavailable") + } + node := filepath.Join(directory, "node.exe") + if info, err := os.Stat(node); err != nil || !info.Mode().IsRegular() { + node, err = exec.LookPath("node") + if err != nil { + return "", nil, errors.New("Node.js unavailable") + } + } + return node, append([]string{cli}, args...), nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go b/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go new file mode 100644 index 000000000..442398204 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go @@ -0,0 +1,81 @@ +//go:build windows + +package localworkspace + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" +) + +func TestWindowsPackageManagerResolution(t *testing.T) { + installation := filepath.Join(t.TempDir(), "selected node") + if err := os.MkdirAll(filepath.Join(installation, "node_modules", "npm", "bin"), 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"node.exe", "npm.cmd", "npx.cmd", "node_modules/npm/bin/npm-cli.js", "node_modules/npm/bin/npx-cli.js"} { + if err := os.WriteFile(filepath.Join(installation, filepath.FromSlash(name)), nil, 0600); err != nil { + t.Fatal(err) + } + } + args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} + for _, name := range []string{"npm", "npx"} { + command := filepath.Join(installation, name+".cmd") + binary, got, err := ResolvePackageManagerCommand(command, args) + want := append([]string{filepath.Join(installation, "node_modules", "npm", "bin", name+"-cli.js")}, args...) + if err != nil || binary != filepath.Join(installation, "node.exe") || !slices.Equal(got, want) { + t.Fatal(binary, got, err) + } + } + for _, name := range []string{"server.exe", "npm.exe", "other.cmd"} { + binary, got, err := ResolvePackageManagerCommand(name, args) + if err != nil || binary != name || !slices.Equal(got, args) { + t.Fatal("ordinary command changed", binary, got, err) + } + } + if err := os.Remove(filepath.Join(installation, "node_modules", "npm", "bin", "npx-cli.js")); err != nil { + t.Fatal(err) + } + if _, _, err := ResolvePackageManagerCommand(filepath.Join(installation, "npx.cmd"), args); err == nil { + t.Fatal("missing selected CLI accepted") + } +} + +func TestWindowsPackageManagerArguments(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("native test requires installed Node.js", err) + } + installation := filepath.Join(t.TempDir(), "npm installation") + directory := filepath.Join(installation, "node_modules", "npm", "bin") + if err = os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", installation+string(os.PathListSeparator)+os.Getenv("PATH")) + args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} + for _, name := range []string{"npm", "npx"} { + if err = os.WriteFile(filepath.Join(installation, name+".cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, name+"-cli.js"), []byte("process.stdout.write(JSON.stringify(process.argv.slice(2)))"), 0600); err != nil { + t.Fatal(err) + } + for _, command := range []string{name, name + ".cmd", filepath.Join(installation, name+".cmd")} { + binary, argv, err := ResolvePackageManagerCommand(command, args) + if err != nil || filepath.Clean(binary) != filepath.Clean(node) { + t.Fatal("PATH Node fallback failed", err) + } + out, err := exec.CommandContext(t.Context(), binary, argv...).Output() + if err != nil { + t.Fatal(err) + } + var got []string + if json.Unmarshal(out, &got) != nil || !slices.Equal(got, args) { + t.Fatal("arguments were interpreted", string(out)) + } + } + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go new file mode 100644 index 000000000..6f3925aa4 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go @@ -0,0 +1,209 @@ +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 := toolEnvironmentPath() + 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) + 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/parsar-daemon/internal/localworkspace/runtime_initialization_process.go new file mode 100644 index 000000000..b054648e2 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go @@ -0,0 +1,141 @@ +package localworkspace + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +func initializationBash() (string, error) { + if runtime.GOOS != "windows" { + if binary, err := exec.LookPath("bash"); err == nil { + return binary, nil + } + return "", initializationDependency("bash") + } + // Git Bash preserves setup's Bash syntax. Never substitute cmd.exe, PowerShell + // or the Windows WSL launcher for a missing Bash implementation. + candidates := []string{} + if explicit := os.Getenv("CLAUDE_CODE_GIT_BASH_PATH"); explicit != "" { + candidates = append(candidates, explicit) + } else if git, err := exec.LookPath("git.exe"); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(git), "..", "bin", "bash.exe"), filepath.Join(filepath.Dir(git), "bash.exe")) + } + for _, candidate := range candidates { + candidate = filepath.Clean(candidate) + if runtimefs.ValidateLocalPath(candidate) != nil { + continue + } + if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() { + return candidate, nil + } + } + return "", initializationDependency("Git Bash") +} + +func initializationPython() (string, error) { + for _, name := range []string{"python3", "python"} { + if binary, err := exec.LookPath(name); err == nil { + return binary, nil + } + } + return "", initializationDependency("Python with pip") +} + +func initializationNPM() (string, []string, error) { + if _, err := exec.LookPath("node"); err != nil { + return "", nil, initializationDependency("Node.js") + } + npm, err := exec.LookPath("npm") + if err != nil { + return "", nil, initializationDependency("npm") + } + binary, args, err := ResolvePackageManagerCommand(npm, nil) + if err != nil { + return "", nil, initializationDependency("npm CLI") + } + return binary, args, nil +} + +func initializationEnvironment(configured map[string]string) []string { + values := map[string]string{} + for _, key := range []string{"PATH", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "PATHEXT", "LANG"} { + if value := os.Getenv(key); value != "" { + values[key] = value + } + } + if runtime.GOOS != "windows" && values["LANG"] == "" { + values["LANG"] = "C.UTF-8" + } + for key, value := range configured { + if runtime.GOOS == "windows" { + for existing := range values { + if strings.EqualFold(existing, key) { + delete(values, existing) + } + } + } + values[key] = value + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+values[key]) + } + return result +} + +// The shared process owner settles the leader and descendants. Readers finish +// before return; output is discarded with constant memory, never put in errors. +func runInitializationProcess(ctx context.Context, binary string, args []string, directory string, env []string) error { + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + if operation.Err() != nil { + return ErrInitializationUnconfirmed + } + if info, err := os.Stat(directory); err != nil || !info.IsDir() { + return &InitializationFailure{} + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, + Dir: directory, Env: env, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return ErrInitializationUnconfirmed + } + defer process.Cancel() + finished := make(chan error, 2) + for _, stream := range []io.Reader{process.Stdout, process.Stderr} { + go func(stream io.Reader) { + _, err := io.Copy(io.Discard, stream) + if err != nil { + process.Cancel() + } + finished <- err + }(stream) + } + first, second := <-finished, <-finished + _ = process.Wait() + if operation.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil { + return ErrInitializationUnconfirmed + } + code := process.Cmd.ProcessState.ExitCode() + if code == 0 { + return nil + } + if code < 1 || code > 255 { + return ErrInitializationUnconfirmed + } + return &InitializationFailure{ExitCode: &code} +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go new file mode 100644 index 000000000..ac8c6246d --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go @@ -0,0 +1,330 @@ +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) { + initializationFixture(t) + file := filepath.Join(t.TempDir(), "explicit.json") + if err := os.WriteFile(file, []byte(`{"DECLARED":"value"}`), 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) + } +} + +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 index cefa50acd..5a9a90ca5 100644 --- a/apps/parsar-daemon/internal/localworkspace/skills.go +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -1,7 +1,6 @@ package localworkspace import ( - "os" "path/filepath" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -9,20 +8,6 @@ import ( const CapabilityDirectory = agentcapabilities.Directory -// LoadCapabilities consumes the packaged installation after binding authorization. -func LoadCapabilities() (agentcapabilities.Manifest, error) { - actual, err := filepath.EvalSymlinks(CapabilityDirectory) - if err != nil || actual != CapabilityDirectory { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot(CapabilityDirectory) - if err != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - defer root.Close() - return agentcapabilities.Load(root) -} - func SkillPath(skill agentcapabilities.InstalledSkill) string { - return filepath.Join(CapabilityDirectory, skill.RelativeRoot) + 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 new file mode 100644 index 000000000..a4038ea1c --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/skills_test.go @@ -0,0 +1,15 @@ +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/localworkspace/snapshot_marker.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go new file mode 100644 index 000000000..a55443dd6 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go @@ -0,0 +1,132 @@ +package localworkspace + +import ( + "encoding/json" + "errors" + "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/google/uuid" +) + +// snapshotMarker remembers completion outside the installed tree. It contains +// only the operator root, never capability configuration or a second inventory. +type snapshotMarker struct { + directory *os.Root + unlock func() + name string + root string + completed bool +} + +func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { + identity := b.capabilityIdentity() + for _, id := range []string{identity.EnvironmentID, identity.SessionID} { + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil || parsed.String() != id { + return nil, agentcapabilities.ErrInvalid + } + } + private, err := paths.Root() + if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil { + return nil, agentcapabilities.ErrInvalid + } + current, err := os.OpenRoot(private) + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + for _, child := range []string{"daemon", "capability-installations"} { + if !privateSnapshotDirectory(current) { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + if err = runtimefs.MkdirPrivate(current, child); err != nil || runtimefs.SyncDirectory(current) != nil { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + next, err := current.OpenRoot(child) + current.Close() + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + current = next + } + if !privateSnapshotDirectory(current) { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + unlock, err := runtimefs.LockDirectory(current) + if err != nil { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + completed, err := marker.read() + if err != nil { + marker.close() + return nil, agentcapabilities.ErrInvalid + } + marker.completed = completed + return marker, nil +} + +func privateSnapshotDirectory(root *os.Root) bool { return runtimefs.PrivateDirectory(root) == nil } + +func (m *snapshotMarker) read() (bool, error) { + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_RDONLY) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return false, err + } + if info.Size() > 8192 { + return false, agentcapabilities.ErrInvalid + } + // Exact bytes also reject duplicate members, trailing data and extra fields. + expected, _ := json.Marshal(struct { + Root string `json:"capability_root"` + }{m.root}) + actual, err := io.ReadAll(io.LimitReader(file, 8193)) + if err != nil || string(actual) != string(expected)+"\n" { + return false, agentcapabilities.ErrInvalid + } + return true, nil +} + +func (m *snapshotMarker) complete() error { + if m.completed { + return nil + } + data, err := json.Marshal(struct { + Root string `json:"capability_root"` + }{m.root}) + if err != nil { + return agentcapabilities.ErrInvalid + } + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return agentcapabilities.ErrInvalid + } + _, writeErr := file.Write(append(data, '\n')) + syncErr := file.Sync() + closeErr := file.Close() + if writeErr != nil || syncErr != nil || closeErr != nil || runtimefs.SyncDirectory(m.directory) != nil { + return agentcapabilities.ErrInvalid + } + m.completed = true + return nil +} + +func (m *snapshotMarker) close() { + m.unlock() + m.directory.Close() +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go new file mode 100644 index 000000000..9c9cf40b4 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go @@ -0,0 +1,222 @@ +package localworkspace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "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" +) + +func markerBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { + t.Helper() + b, request := testBinding(t) + // This host's inherited ACL gives TempDir group permissions unless cleared. + if err := os.Chmod(os.Getenv("OAC_RUNTIME_HOME"), 0700); err != nil { + t.Fatal(err) + } + configured, err := b.Configure(request) + if err != nil { + t.Fatal(err) + } + return b, configured +} +func markerPath(b *Binding) string { + identity := b.capabilityIdentity() + return filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") +} +func TestSnapshotMarkerRoundTrip(t *testing.T) { + b, _ := markerBinding(t) + m, err := b.openSnapshotMarker() + if err != nil { + t.Fatal(err) + } + if m.completed { + t.Fatal("fresh marker complete") + } + if err := m.complete(); err != nil { + t.Fatal(err) + } + m.close() + info, err := os.Stat(markerPath(b)) + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Fatal("marker not private", err) + } + raw, err := os.ReadFile(markerPath(b)) + if err != nil { + t.Fatal(err) + } + var fields map[string]string + if json.Unmarshal(raw, &fields) != nil || len(fields) != 1 || fields["capability_root"] != b.capabilityRoot { + t.Fatal("marker contains more than installation root") + } + m, err = b.openSnapshotMarker() + if err != nil { + t.Fatal(err) + } + defer m.close() + if !m.completed { + t.Fatal("completion not retained") + } + if err := m.complete(); err != nil { + t.Fatal("completed marker rewritten", err) + } +} + +func TestCompletedSnapshotLossNeverRecapturesSources(t *testing.T) { + for _, populated := range []bool{false, true} { + for _, removeRoot := range []bool{false, true} { + name := map[bool]string{false: "empty", true: "directory"}[populated] + "/" + map[bool]string{false: "manifest", true: "root"}[removeRoot] + t.Run(name, func(t *testing.T) { + b, request := markerBinding(t) + source := t.TempDir() + if populated { + writeSourceSkill(t, source, "first") + request.LocalEnvironment.Capabilities = true + request.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + } + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal(err) + } + marker, err := os.ReadFile(markerPath(b)) + if err != nil { + t.Fatal(err) + } + if removeRoot { + if err := os.RemoveAll(b.capabilityRoot); err != nil { + t.Fatal(err) + } + } else if err := os.Remove(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); err != nil { + t.Fatal(err) + } + if populated { + writeSourceSkill(t, source, "changed") + } + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("lost snapshot recreated") + } + if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("manifest recreated", err) + } + if removeRoot { + if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { + t.Fatal("installation root recreated", err) + } + } + after, _ := os.ReadFile(markerPath(b)) + if string(after) != string(marker) { + t.Fatal("completion evidence changed") + } + }) + } + } +} + +func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { + b, request := markerBinding(t) + source := t.TempDir() + writeSourceSkill(t, source, "frozen") + input := agentcapabilities.Input{Directories: []string{source}} + root, err := os.OpenRoot(b.capabilityRoot) + if err != nil { + t.Fatal(err) + } + if err := agentcapabilities.Finalize(root, input, b.capabilityIdentity(), b.resolveCapabilityDirectory); err != nil { + t.Fatal(err) + } + root.Close() + if err := os.RemoveAll(source); err != nil { + t.Fatal(err) + } + request.LocalEnvironment.Capabilities = true + request.LocalEnvironment.CapabilitySources = &input + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal("valid manifest was not recovered without sources", err) + } + if _, err := os.Stat(markerPath(b)); err != nil { + t.Fatal("verified completion not recorded", err) + } + previousRoot := b.capabilityRoot + b.capabilityRoot = t.TempDir() + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("operator root changed after completion") + } + if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("replacement root installed", err) + } + b.capabilityRoot = previousRoot +} + +func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { + for _, kind := range []string{"corrupt", "foreign root"} { + t.Run(kind, func(t *testing.T) { + b, request := markerBinding(t) + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal(err) + } + name := markerPath(b) + switch kind { + case "corrupt": + if err := os.WriteFile(name, []byte("{"), 0600); err != nil { + t.Fatal(err) + } + case "foreign root": + if err := os.WriteFile(name, []byte("{\"capability_root\":\"/another\"}\n"), 0600); err != nil { + t.Fatal(err) + } + + } + before, _ := os.ReadFile(name) + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("invalid completion evidence accepted") + } + after, _ := os.ReadFile(name) + if string(before) != string(after) { + t.Fatal("invalid evidence overwritten") + } + }) + } +} + +func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { + b, _ := markerBinding(t) + identity := b.capabilityIdentity() + finalize := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} + if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(markerPath(b)); err != nil { + t.Fatal("finalize omitted marker", err) + } + // Even a fresh empty root cannot make an already completed identity writable. + if err := os.RemoveAll(b.capabilityRoot); err != nil { + t.Fatal(err) + } + skill := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := b.ApplyRuntimePreparation(t.Context(), skill, []byte("x")); err == nil { + t.Fatal("completed identity accepted import") + } + if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err == nil { + t.Fatal("completed identity finalized again") + } + if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { + t.Fatal("rejected operation recreated root", err) + } +} + +func TestSnapshotMarkerCancellationDoesNotPublishCompletion(t *testing.T) { + b, request := markerBinding(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := b.Prepare(ctx, request); err == nil { + t.Fatal("cancelled preparation accepted") + } + if _, err := os.Stat(markerPath(b)); !os.IsNotExist(err) { + t.Fatal("cancelled preparation published marker", err) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go index 15aaf726d..53ce99910 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -1,23 +1,16 @@ package localworkspace import ( - "bytes" "context" - "crypto/sha256" - "encoding/json" "errors" - "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -// This private bound matches the existing installer; it is not an upstream limit. +// This private transfer bound is distinct from the public inline-file limit. const WriteMaxBytes = proto.WorkspaceWriteMaxBytes var _ agent.WorkspaceWriter = (*Binding)(nil) @@ -47,66 +40,6 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by return result, agent.ErrWorkspaceWriteUncertain } defer func() { w.uncertain = errors.Is(err, agent.ErrWorkspaceWriteUncertain) }() - // Once admitted, observer cancellation cannot turn a possible commit into a - // rejection. Missing results poison this Runtime writer, even after local reap. - operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second) - defer cancel() - digest := sha256.Sum256(data) - // This writer serves only public Files.create, so it always selects the - // helper's create mode: create missing parents and never replace. - cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create") - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:])) - output := &writeOutput{} - cmd.Stdout = output - cmd.WaitDelay = time.Second - if err := cmd.Start(); err != nil { - return result, agent.ErrWorkspaceWriteUnavailable - } - if err := cmd.Wait(); err != nil { - return result, agent.ErrWorkspaceWriteUncertain - } - return decodeWrite(output.Bytes(), len(data)) -} - -// A malformed helper cannot grow the daemon's output buffer without bound. -type writeOutput struct{ data bytes.Buffer } - -func (b *writeOutput) Bytes() []byte { return b.data.Bytes() } - -func (b *writeOutput) Write(p []byte) (int, error) { - if len(p) > 1024-b.data.Len() { - return 0, errors.New("local write response exceeds limit") - } - return b.data.Write(p) -} - -func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error) { - var wire struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - SizeBytes *int64 `json:"size_bytes"` - Error *string `json:"error"` - } - invalid := agent.ErrWorkspaceWriteUncertain - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 { - return agent.WorkspaceWriteResult{}, invalid - } - if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) { - return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil - } - if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil { - switch *wire.Error { - case "invalid_input", "write_failed": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected - case "destination_directory": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteDirectory - case "unsafe_destination": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsafe - } - } - return agent.WorkspaceWriteResult{}, invalid + // Once admitted, finish this synchronous mutation before returning ownership. + return b.writeNativeFile(context.WithoutCancel(ctx), path, data) } diff --git a/apps/parsar-daemon/internal/localworkspace/write_binding.go b/apps/parsar-daemon/internal/localworkspace/write_binding.go index c656e8cfb..e5d54ab16 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_binding.go +++ b/apps/parsar-daemon/internal/localworkspace/write_binding.go @@ -1,45 +1,8 @@ package localworkspace -import ( - "errors" - "os" - "path/filepath" - "strings" - "sync" -) +import "sync" type fileWriter struct { - helper string - staging string mu sync.Mutex uncertain bool } - -// bindWriter runs only during startup. Path checks do not prove native isolation; -// deployment qualification must deny tools access to staging and its ancestors. -func (b *Binding) bindWriter(helper, staging string) error { - invalid := errors.New("local file writer requires a protected sibling staging directory and external executable") - parent := filepath.Dir(b.workspace) - if parent == "/" || filepath.Dir(staging) != parent || staging == b.workspace { - return invalid - } - for _, name := range []string{b.workspace, staging, helper} { - if !filepath.IsAbs(name) || filepath.Clean(name) != name || strings.ContainsAny(name, "\x00\r\n\\") { - return invalid - } - resolved, err := filepath.EvalSymlinks(name) - if err != nil || resolved != name { - return invalid - } - } - dir, err := os.Stat(staging) - if err != nil || !dir.IsDir() { - return invalid - } - program, err := os.Stat(helper) - if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || helper == parent || strings.HasPrefix(helper, parent+string(filepath.Separator)) { - return invalid - } - b.writer = &fileWriter{helper: helper, staging: staging} - return nil -} diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go index 3e46a4164..5d953a421 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -5,221 +5,75 @@ import ( "context" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" ) -func writableBinding(t *testing.T, script string) *Binding { +func nativeFileBinding(t *testing.T) *Binding { t.Helper() - b, _ := testBinding(t) - // macOS test roots may contain /var aliases. Writer deployment paths must be canonical. - parent, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - b.workspace = filepath.Join(parent, "workspace") - staging := filepath.Join(parent, "staging") - for _, p := range []string{b.workspace, staging} { - if err := os.Mkdir(p, 0700); err != nil { - t.Fatal(err) + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} +func TestNativeFileCreateAndNoReplace(t *testing.T) { + b := nativeFileBinding(t) + for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { + name := fmt.Sprintf("nested/file-%d", size) + data := bytes.Repeat([]byte{17}, size) + got, err := b.WriteWorkspaceFile(t.Context(), name, data) + if err != nil || got.SizeBytes != int64(size) { + t.Fatal(size, got, err) + } + if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, agent.ErrWorkspaceWriteUnsafe) { + t.Fatal("existing file replaced", err) + } + raw, err := os.ReadFile(filepath.Join(b.workspace, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(raw, data) { + t.Fatal("file content changed", err) } } - helper, err := filepath.EvalSymlinks(b.helper) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+script), 0700); err != nil { - t.Fatal(err) - } - if err := b.bindWriter(helper, staging); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, agent.ErrWorkspaceWriteDirectory) { t.Fatal(err) } - return b -} - -func TestWriteRejectsUnsafeBindings(t *testing.T) { - b := writableBinding(t, "exit 1\n") - link := filepath.Join(filepath.Dir(b.workspace), "alias") - if err := os.Symlink(b.writer.staging, link); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { t.Fatal(err) } - inside := filepath.Join(b.workspace, "helper") - if err := os.WriteFile(inside, []byte("#!/bin/sh\n"), 0700); err != nil { + entries, err := os.ReadDir(b.workspace) + if err != nil { t.Fatal(err) } - for _, pair := range [][2]string{{"", b.writer.staging}, {b.writer.helper, ""}, {b.writer.helper, b.workspace}, {b.writer.helper, link}, {inside, b.writer.staging}, {b.writer.helper, filepath.Dir(b.workspace)}} { - if err := b.bindWriter(pair[0], pair[1]); err == nil { - t.Fatalf("unsafe writer accepted: %q", pair) + for _, entry := range entries { + if len(entry.Name()) >= 11 && entry.Name()[:11] == ".oac-write-" { + t.Fatal("temporary retained") } } } - -func TestWriteReceiptAndCredentialBoundary(t *testing.T) { - t.Setenv("OAC_TEST_PRIVATE_CREDENTIAL", "synthetic-secret") - // Files.create always selects the helper's explicit create mode. - b := writableBinding(t, "[ -z \"$OAC_TEST_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") - result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2}) - if err != nil || result.SizeBytes != 3 { - t.Fatalf("write: %+v %v", result, err) - } - for _, p := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { - if _, err := b.WriteWorkspaceFile(t.Context(), p, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { - t.Fatalf("path %q: %v", p, err) +func TestNativeFileAdmission(t *testing.T) { + b := nativeFileBinding(t) + for _, path := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { + if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(path, err) } } - if _, err := b.WriteWorkspaceFile(t.Context(), "file", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { t.Fatal(err) } -} - -func TestWriteDetachmentAndConcurrentAdmission(t *testing.T) { - b := writableBinding(t, "cat >/dev/null\ntouch \"$1/started\"\nwhile [ ! -f \"$1/release\" ]; do sleep 0.01; done\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\n") ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { _, err := b.WriteWorkspaceFile(ctx, "file", nil); done <- err }() - defer os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600) - deadline := time.After(5 * time.Second) - for { - if _, err := os.Stat(filepath.Join(b.workspace, "started")); err == nil { - break - } - select { - case <-deadline: - t.Fatal("helper did not start") - case <-time.After(5 * time.Millisecond): - } - } cancel() - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteBusy) { - t.Fatalf("overlap: %v", err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600); err != nil { + if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, agent.ErrWorkspaceWriteUnavailable) { t.Fatal(err) } - select { - case err := <-done: - if err != nil { - t.Fatal("detached admitted write lost receipt", err) - } - case <-time.After(5 * time.Second): - t.Fatal("write did not finish") - } -} - -func TestWriteUncertaintyStopsSuccessor(t *testing.T) { - for name, script := range map[string]string{ - "missing": "cat >/dev/null\nexit 0\n", - "wrong-size": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":8}'\n", - "overflow": "cat >/dev/null\nhead -c 2048 /dev/zero\n", - "exit": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\nexit 1\n", - } { - t.Run(name, func(t *testing.T) { - b := writableBinding(t, script) - if _, err := b.WriteWorkspaceFile(t.Context(), "file", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("first: %v", err) - } - if err := os.WriteFile(b.writer.helper, []byte("#!/bin/sh\ntouch \"$1/forbidden\"\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("successor: %v", err) - } - if _, err := os.Stat(filepath.Join(b.workspace, "forbidden")); !os.IsNotExist(err) { - t.Fatal("unknown write admitted successor") - } - }) - } -} - -func TestWriteReceiptValidation(t *testing.T) { - for _, response := range []string{ - `{"version":1,"outcome":"completed"}`, - `{"version":1,"outcome":"completed","size_bytes":0,"error":"write_failed"}`, - `{"version":1,"outcome":"failed","size_bytes":0,"error":"write_failed"}`, - `{"version":1,"outcome":"unknown","error":"write_failed"}`, - `{"version":1,"outcome":"failed","error":"other"}`, - `{"version":1,"outcome":"completed","size_bytes":0} {}`, - `{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`, - `{"version":1,"outcome":"unknown","error":"unsafe_destination"}`, - `{"version":1,"outcome":"failed","size_bytes":0,"error":"destination_directory"}`, - } { - if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("unsafe receipt %s: %v", response, err) - } - } - for code, want := range map[string]error{ - "invalid_input": agent.ErrWorkspaceWriteRejected, - "write_failed": agent.ErrWorkspaceWriteRejected, - "destination_directory": agent.ErrWorkspaceWriteDirectory, - "unsafe_destination": agent.ErrWorkspaceWriteUnsafe, - } { - _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0) - if err != want || !errors.Is(err, agent.ErrWorkspaceWriteRejected) { - t.Fatal(code, err) - } - } -} - -func TestLocalWriteNativeInstaller(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") - if helper == "" { - t.Skip("requires the built native installer") - } - b := writableBinding(t, "exit 1\n") - if err := b.bindWriter(helper, b.writer.staging); err != nil { - t.Fatal(err) + if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { + t.Fatal("cancelled request mutated workspace") } - escape := filepath.Join(b.workspace, "escape") - if err := os.Symlink(b.writer.staging, escape); err != nil { + b.writer.mu.Lock() + _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) + b.writer.mu.Unlock() + if !errors.Is(err, agent.ErrWorkspaceWriteBusy) { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil { + b.writer.uncertain = true + if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { t.Fatal(err) } - // Missing parents are created; an existing destination is never replaced. - for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { - path := fmt.Sprintf("n1/n2/file-%d", size) - data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size] - got, err := b.WriteWorkspaceFile(t.Context(), path, data) - if err != nil || got.SizeBytes != int64(size) { - t.Fatalf("size %d: %+v %v", size, got, err) - } - actual, err := os.ReadFile(filepath.Join(b.workspace, path)) - if err != nil || !bytes.Equal(actual, data) { - t.Fatalf("size %d bytes differ: %v", size, err) - } - } - if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - t.Fatal("parent directory", info, err) - } - for path, want := range map[string]error{ - "n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe, - "setup.txt": agent.ErrWorkspaceWriteUnsafe, - "escape": agent.ErrWorkspaceWriteUnsafe, - "escape/new": agent.ErrWorkspaceWriteUnsafe, - "n1/n2": agent.ErrWorkspaceWriteDirectory, - "n1": agent.ErrWorkspaceWriteDirectory, - } { - if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want { - t.Fatalf("%s: %v", path, err) - } - } - if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) { - t.Fatal("existing file changed", err) - } - if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" { - t.Fatal("untracked file changed", err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { - t.Fatal("known rejection blocked next write", err) - } - entries, err := os.ReadDir(b.writer.staging) - if err != nil || len(entries) != 0 { - t.Fatal("staging retained successful/known-rejected data", err) - } } diff --git a/apps/parsar-daemon/internal/paths/paths.go b/apps/parsar-daemon/internal/paths/paths.go index c302e2564..6b48dc6bb 100644 --- a/apps/parsar-daemon/internal/paths/paths.go +++ b/apps/parsar-daemon/internal/paths/paths.go @@ -8,9 +8,11 @@ package paths import ( "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" "path/filepath" "regexp" + "strings" ) const DefaultProfile = "default" @@ -25,7 +27,9 @@ func ValidateProfile(name string) error { if name == "" { return fmt.Errorf("profile name must not be empty") } - if !profilePattern.MatchString(name) { + base := strings.ToUpper(strings.SplitN(name, ".", 2)[0]) + reserved := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') + if strings.HasSuffix(name, ".") || reserved || !profilePattern.MatchString(name) { return fmt.Errorf("profile %q must match %s", name, profilePattern.String()) } return nil @@ -35,6 +39,9 @@ func ValidateProfile(name string) error { // sandbox environments without a writable home. func Root() (string, error) { if override := os.Getenv("OAC_RUNTIME_HOME"); override != "" { + if !filepath.IsAbs(override) || filepath.Clean(override) != override { + return "", fmt.Errorf("OAC_RUNTIME_HOME must be a clean absolute directory") + } return override, nil } home, err := os.UserHomeDir() @@ -64,7 +71,7 @@ func EnsureProfileDir(profile string) (string, error) { if err != nil { return "", err } - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return "", fmt.Errorf("create profile dir %s: %w", dir, err) } return dir, nil diff --git a/apps/parsar-daemon/internal/paths/paths_test.go b/apps/parsar-daemon/internal/paths/paths_test.go index 053f4b94d..4f42c4057 100644 --- a/apps/parsar-daemon/internal/paths/paths_test.go +++ b/apps/parsar-daemon/internal/paths/paths_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -15,7 +16,10 @@ import ( // we want. func withTempHome(t *testing.T) string { t.Helper() - dir := t.TempDir() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } t.Setenv("OAC_RUNTIME_HOME", dir) return dir } @@ -29,7 +33,7 @@ func TestValidateProfile(t *testing.T) { } bad := []string{ "", - "../escape", + "../escape", ".", "..", "profile.", "CON", "nul.json", "com1", "LPT9", "with/slash", "with\\backslash", "with space", @@ -102,7 +106,7 @@ func TestEnsureProfileDirCreates0700(t *testing.T) { if !info.IsDir() { t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) } - if mode := info.Mode().Perm(); mode != 0o700 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o700 { t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) } @@ -128,3 +132,10 @@ func TestInvalidProfileShortCircuits(t *testing.T) { t.Fatal("EnsureProfileDir accepted invalid profile name") } } + +func TestRootRejectsRelativeOverride(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", "relative") + if _, err := paths.Root(); err == nil { + t.Fatal("relative private home accepted") + } +} diff --git a/apps/parsar-daemon/internal/transport/ws.go b/apps/parsar-daemon/internal/transport/ws.go index 8ea9d2f5f..93f7f95f4 100644 --- a/apps/parsar-daemon/internal/transport/ws.go +++ b/apps/parsar-daemon/internal/transport/ws.go @@ -33,7 +33,7 @@ type DialOptions struct { Credential string // DaemonVersion is the X.Y.Z string used for - // proto.VersionCompatible. Mismatches close at upgrade time (4126). + // proto.VersionCompatible. Mismatches reject the HTTP upgrade with 426. DaemonVersion string // HandshakeTimeout caps WS upgrade wait. Zero → 10s. @@ -192,7 +192,7 @@ func (c *Conn) Close() error { // StartHeartbeats kicks off a ticker that calls payloadFn every // interval and Sends the resulting HeartbeatPayload. Returns // immediately. Caller-controlled because the heartbeat carries fields -// (active_requests, claude_available) only the agent layer knows. +// (active_requests, supported_agent_kinds) only the agent layer knows. // Nil logger falls back to log.Bg(). func (c *Conn) StartHeartbeats(parentCtx context.Context, interval time.Duration, payloadFn func() proto.HeartbeatPayload, logger *slog.Logger) { if interval <= 0 || payloadFn == nil { diff --git a/apps/parsar-daemon/testdata/onboarding/main.go b/apps/parsar-daemon/testdata/onboarding/main.go index 67a29f92b..679f98338 100644 --- a/apps/parsar-daemon/testdata/onboarding/main.go +++ b/apps/parsar-daemon/testdata/onboarding/main.go @@ -28,34 +28,81 @@ func (s *sender) Send(_ context.Context, e proto.Envelope) error { return s.encoder.Encode(e) } -type harness struct{ history map[string]string } +type harness struct { + mu sync.Mutex + history map[string]string +} -func (h *harness) start(_ context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if _, err := req.Input.TextOnly(); err != nil { - return nil, err +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.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + 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 { + if req.AgentSessionID != previous || (req.RequireExistingNativeSession && previous == "") { return nil, errors.New("native history mismatch") } - id := previous - if id == "" { - id = "fixture-" + req.AgentStateKey - h.history[req.AgentStateKey] = id + 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() } - s := &session{out: out, run: req.RunID, native: id} + 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) { @@ -68,6 +115,8 @@ func (s *session) Cancel(context.Context) error { if !s.closed { s.closed = true close(s.out) + s.release() + close(s.settled) } return nil } @@ -92,15 +141,29 @@ func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload 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: proto.AgentKindCapabilities{ Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, ToolObservations: true, SubagentControl: true, EnvironmentNone: true, - }}, h.start) + }}, 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 { diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index ef8ab9f2f..af7e292af 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -495,16 +495,18 @@ Every resource list, the Session log and the project list share one grammar: page body to the next failed Turn and focuses it. - An active project's page ends its keys with a **How to call** section (see Dialogs) before its write operations. -- A self-hosted Session's **Executor credentials** section ends with a **Connect - a host** card when the console serves the self-hosted installer: a 13px/600 - title with a help tip (what revoke and rotate do to the host, how to reconnect, - how to remove the Runtime), one Graphite line with the steps (run the command, - paste a credential at its hidden prompt, safe to rerun), the command in a - Margin Gray Terminal block with an icon copy button, and the host requirements - on one dot-separated line. The command wraps rather than scrolls. Without a - public address, with a loopback one, or with a Session address that is not - `wss://`, one Graphite note takes the command's place; an archived project - keeps the command and says the host still needs a credential. +- A self-hosted Session's **Executor credentials** section ends with **Connect + a host**, independent of console installer assets. Keep native distribution + guidance, a Linux/macOS or PowerShell selector and one copyable command here. + The command pre-fills the Session remote URL, Environment ID and workspace; + interactive installation asks for a privately saved credential file and local + installation choices. Link the native guide instead of inventing a release + download URL. Requirements and reconnection details belong in the title help. + Reconnection after credential rotation requires `stop`, replacement of the + configured credential file, then `start`; disconnection does not imply process exit. + Installation does not start the daemon; connection status comes only from Core. + Missing connection facts show a note instead of a command. Loopback ws is valid + for native local connections. Archived projects require an existing credential. ### Dialogs Dialogs are 448px Paper cards with 8px corners, a 48px header and a 52px footer @@ -526,15 +528,12 @@ request runs. red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. -- **Executor credential dialog** (640px): the shown-once notice, then one line - saying what to do in order. With Connect a host available, the install - command's Terminal block comes first, so it is copied and run before the - credential is pasted and Done pressed. Then the credential as one line of JSON - (wrapped, never pretty-printed), Copy credential (primary: it is pasted at the - installer's hidden prompt) and Download credential file (outline), with a - Graphite hint for automation (`chmod 600`, `--credential-file`). Done is - outline and forgets the credential; closing the dialog keeps it in a pending - card, which points to the Connect a host command below. +- **Executor credential dialog** (640px): the shown-once notice, then a prompt + to save the JSON privately before Done. Download credential file is primary; + Copy credential is secondary. Installation commands are not repeated here. + Done forgets the credential; closing preserves it in the pending card. The + native installer reads the unchanged JSON file through its interactive prompt + or `--credential-file`; tokens never enter command arguments. - **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index af4851e24..d97b1ea73 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -54,9 +54,8 @@ workbench. - 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`) and the self-hosted executor installer - (`self_hosted_installer`, `self_hosted_installer_sha256`); an installer is - offered only with a 64-hex digest. It also lists the providers it has node files + `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, so sandbox administration is available unless the console explicitly reports @@ -204,8 +203,8 @@ workbench. executor needs, with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown once as one line of JSON, to copy or download, never stored), rotate it (the - old one stops working immediately) or revoke it (the executor disconnects and - won't retry; its container keeps running until stopped). The file lets one + old one stops working immediately) or revoke it (the executor disconnects; + installed Runtime state and workspace contents are not deleted). The file lets one executor connect for that environment only; it cannot call the Agents API. - **Default provider observations.** Each configured harness offers Usage details for Core's last successful use and any newer classified provider error. Missing @@ -217,19 +216,20 @@ workbench. share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks - Run on host done. Stale or failed reads withhold completion. Recovery rotates - the bound key and reruns the same command rather than suggesting a new key. -- **Connect a host.** When the console serves the self-hosted installer, the - section also gives the command that installs the executor on the - administrator's host from Core's `public_url` (checksum-verified, no secret in - it; the installer asks for the credential at a hidden prompt, or reads - `--credential-file`). The same command is safe to rerun. Revoking or rotating - disconnects the host's executor; reconnecting takes that same credential, - rotated (Restore on a revoked row rotates and restores it), and the same command, because a - newly issued credential does not reconnect an environment that already - connected. Without a `public_url`, with a loopback one, or when the Session's - `remote_url` is not `wss://`, the section says why instead of showing a - command. + Host connected. Stale or failed reads withhold completion. Recovery rotates + the bound key, stops the installed daemon, replaces the host credential file + and starts the daemon again. +- **Connect a host.** Native Linux/macOS and PowerShell installation instructions + depend on the Session's remote URL, Environment ID and workspace, not console + installer flags or served Python assets. Users privately save the issued JSON, + obtain a matching native distribution through the linked guide, and run the + interactive install command from its root. Installation asks for the credential + file path and does not automatically start the daemon. Run the installed binary + in the installation's bin directory with `start`. No model readiness is implied. + Credential rotation requires stopping the installed daemon, replacing the + configured file and starting that same daemon again. A disconnected daemon may + still be running; `start` alone does not replace it. A new key cannot reconnect an already-bound Environment. + Accept wss or loopback ws; withhold commands for missing or invalid facts. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts index 63948f790..962380260 100644 --- a/apps/web/e2e/diagnostics.spec.ts +++ b/apps/web/e2e/diagnostics.spec.ts @@ -74,7 +74,7 @@ test("connection reads govern host completion and bound-key guidance", async ({ await openConsole(page, request, "sessions"); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Disconnected"); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Rotate bound credential", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); revoked = true; @@ -84,9 +84,9 @@ test("connection reads govern host completion and bound-key guidance", async ({ revoked = false; connected = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Connected", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toBeVisible(); + await expect(page.getByText("Host connected", { exact: true })).toBeVisible(); unavailable = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Unknown", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b79218fd6..3051a667d 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -36,7 +36,6 @@ const LOCAL_URL = "http://127.0.0.1:8091"; const OLD_URL = "https://core-old.example.com"; const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); /** The digest the console reports for its self-hosted executor installer; the same value as in monitoring.spec.ts. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; @@ -201,14 +200,11 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { - // As Core's console: signing in grants administration, so it reports only its installers, - // both served from the node installation payload, and without that payload neither, and the - // providers whose node files that payload holds (always a list, empty without it). + // Console assets cover node enrollment only; native self-hosted installation is independent. const served = state.installers; return send(response, 200, { node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "", node_artifacts: served ? state.nodeArtifacts : [], - self_hosted_installer: served, self_hosted_installer_sha256: served ? SELF_HOSTED_INSTALLER_SHA256 : "", }); } return error(response, 404, "Not found."); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 4e73326f3..84ceb0b11 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -6,7 +6,6 @@ import { archiveProject, expectManagementBoundary, openConsole } from "./console test.afterEach(async ({ request }) => expectManagementBoundary(request)); /** The self-hosted installer digest the fixture console reports. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => { await openConsole(page, request, "overview"); @@ -81,22 +80,21 @@ test("shows a self-hosted Session's install command, issues its credential once, // Connect a host: the exact install command, which carries no secret. const install = section.getByRole("region", { name: "Connect a host" }); - await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example.com/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${SELF_HOSTED_INSTALLER_SHA256}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-id '${environmentId}' --remote 'wss://core.example.com/api/v1/agent-daemon/ws')`); - await expect(install.getByRole("list", { name: "Host requirements" })).toContainText("HTTPS access to https://core.example.com"); + await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`./oac-daemon install --interactive --remote 'wss://core.example.com/api/v1/agent-daemon/ws' --environment-id '${environmentId}' --workspace '/srv/work'`); + await expect(install.getByRole("link")).toHaveAttribute("href", /docs\/self-hosted-native.md$/); + await install.getByRole("combobox", { name: "Host platform" }).click(); + await page.getByRole("option", { name: "Windows · PowerShell" }).click(); + await expect(install.locator("pre")).toContainText(".\\oac-daemon.exe install --interactive"); + await install.screenshot({ path: test.info().outputPath("native-host.png") }); await section.getByRole("button", { name: "Issue credential" }).click(); const issued = page.getByRole("dialog", { name: "Executor credential" }); await expect(issued).toContainText("shown only once"); - // The command comes with it, so it can be run before the credential is pasted and the dialog closed. - await expect(issued).toContainText("Run this command on the host first, then paste the credential below at its prompt and press Done."); - await expect(issued.getByLabel("Executor install command")).toContainText("self-hosted-install.pyz"); - // One line of JSON to paste at the installer's hidden prompt; the file is for automation. - await expect(issued.getByRole("button", { name: "Copy credential" })).toHaveClass(/\bprimary\b/); + await expect(issued).toContainText("Download and privately save the credential file before choosing Done."); + await expect(issued.getByLabel("Executor install command")).toHaveCount(0); + await expect(issued.getByRole("button", { name: "Download credential file" })).toHaveClass(/\bprimary\b/); await expect(issued.getByLabel("Executor credential file")).toHaveText(new RegExp(`^\\{"key_id":"[0-9a-f-]{36}","environment_id":"${environmentId}","executor_token":"exec_fixture_\\d+"\\}$`)); - await expect(issued).toContainText("run chmod 600 and add --credential-file to the python3 line; the path must not go through a symlink"); + await expect(issued).toContainText("Enter its absolute path during interactive installation, or use --credential-file "); const download = page.waitForEvent("download"); await issued.getByRole("button", { name: "Download credential file" }).click(); expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); @@ -122,7 +120,7 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await credentials.getByRole("button", { name: /^Restore credential / }).click(); const rotation = page.getByRole("dialog", { name: "Restore credential?" }); await expect(rotation).toContainText("generating a new secret for the same credential"); - await expect(rotation).toContainText("rerun the Connect a host command there and paste the new credential at its prompt"); + await expect(rotation).toContainText("Run the installed daemon’s stop command, replace the configured credential file on the host, then run start to reconnect."); await rotation.getByRole("button", { name: "Restore" }).click(); const restored = page.getByRole("dialog", { name: "Executor credential" }); await expect(restored.getByLabel("Executor credential file")).toContainText("exec_fixture_2"); @@ -140,25 +138,22 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); await expect(credentials.getByRole("button", { name: /^Revoke credential / })).toBeVisible(); // The command stays; the note says the host still needs a credential. - await expect(install).toContainText("It asks for a credential, which this archived project can't issue or rotate"); + await expect(install).toContainText("Installation requires an existing credential file"); }); -test("explains instead of giving the install command when Core's public address is loopback", async ({ page, request }) => { +test("offers the native command for a loopback Core", async ({ page, request }) => { await openConsole(page, request, "sessions", { installation: "local" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const install = page.getByRole("region", { name: "Connect a host" }); - await expect(install).toContainText("Core's public address http://127.0.0.1:8091 is reachable only on the Core machine"); - await expect(install.locator("pre")).toHaveCount(0); + await expect(install.locator("pre")).toContainText("ws://127.0.0.1:8091/api/v1/agent-daemon/ws"); }); -test("hides Connect a host when the console does not serve the self-hosted installer", async ({ page, request }) => { - const config = page.waitForResponse((response) => new URL(response.url()).pathname === "/console/config"); +test("offers native installation without console installer assets", async ({ page, request }) => { await openConsole(page, request, "sessions", { installers: "none" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const section = page.getByRole("region", { name: "Executor credentials" }); await expect(section).toContainText("No executor credentials yet"); - await config; - await expect(section.getByRole("region", { name: "Connect a host" })).toHaveCount(0); + await expect(section.getByRole("region", { name: "Connect a host" })).toContainText("install --interactive"); await expect(section.getByRole("button", { name: "Issue credential" })).toBeVisible(); }); @@ -186,7 +181,7 @@ test("issues a new executor credential after the unanswered one was rotated from const credentials = section.getByRole("table", { name: "Executor credentials" }); await credentials.getByRole("button", { name: /^Rotate credential / }).click(); // Rotating an active credential disconnects its host until the command is rerun with the new one. - await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host's executor disconnects and won't retry until you rerun the Connect a host command"); + await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host disconnects. Run the installed daemon’s stop command, replace its configured credential file, then run start."); await page.getByRole("dialog", { name: "Rotate credential?" }).getByRole("button", { name: "Rotate" }).click(); await page.getByRole("dialog", { name: "Executor credential" }).getByRole("button", { name: "Done" }).click(); diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index c314cd666..168072bfb 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -138,9 +138,13 @@ test("an applied reset with a lost response stays blocked through failed reads a await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); - let failReads = true; + let failReads = false; await page.route(resetURL, async (route) => { - await route.fetch(); // Core accepted the mutation, but its response never reached this browser. + if (route.request().method() !== "POST") return route.continue(); + const accepted = await route.fetch(); // Core accepted the mutation, but its response never reached this browser. + expect(accepted.ok()).toBe(true); + // Only fail reconciliation reads after acceptance; earlier reads must allow the explicit submission. + failReads = true; await route.abort("failed"); }); await page.route(deploymentURL, (route) => failReads ? route.fulfill(unavailable) : route.continue()); 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 d81903d95..928d5c408 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -23,7 +23,7 @@ function Probe() { function render(latest: SandboxDeployment, previous = configured, failed = false) { const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const snapshot: FleetSnapshot = { deployment: previous, nodes: [node("n1")], allocations: [], loadedAt: 1 }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; cache.setQueryData(consoleConfigQuery.queryKey, () => config); cache.setQueryData(fleetQuery(false).queryKey, snapshot); cache.setQueryData(sandboxDeploymentQuery.queryKey, latest); diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index c2033bbc9..250d3c991 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -65,8 +65,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, open: boolean; fresh: boolean; onClose: () => void; - /** Reads the page's node list again; settles when the read has. */ - onRefresh: () => Promise; + /** Returns the confirmed node list, or null when the read failed. */ + onRefresh: () => Promise; }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; @@ -85,8 +85,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const [copiedMode, setCopiedMode] = useState("sudo"); const queryClient = useQueryClient(); const installation = useQuery(installationQuery); - // When the latest node-list read this dialog asked for began (Date.now()), once it has finished. - const [checkedAt, setCheckedAt] = useState(0); + // Keep the completed read's start time paired with its data: query notifications can render later. + const [checked, setChecked] = useState<{ startedAt: number; nodes: SandboxNode[] } | null>(null); const reading = useRef(false); const generation = useRef(0); const request = useRef(null); @@ -137,7 +137,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const expiresAt = enrollment ? Date.parse(enrollment.expires_at) : 0; const lapsed = Boolean(enrollment && expiresAt <= now); // Expired only once a read begun after the expiry found no node for the command. - const expired = lapsed && checkedAt >= expiresAt; + const expired = lapsed && fresh && checked !== null && checked.startedAt >= expiresAt && checked.nodes === nodes; const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && publicUrl && (registered || !expired) && !ready ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; const command = commandFor("sudo"); @@ -147,8 +147,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, if (reading.current) return; reading.current = true; const started = Date.now(); - try { await onRefresh(); } finally { reading.current = false; } - setCheckedAt((current) => Math.max(current, started)); + try { + const confirmedNodes = await onRefresh(); + if (confirmedNodes) setChecked({ startedAt: started, nodes: confirmedNodes }); + } finally { reading.current = false; } }, [onRefresh]); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); @@ -167,8 +169,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, useEffect(() => { if (open && enrollment) void check(); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps // At expiry, one more read decides between the command's node and "Command expired". useEffect(() => { - if (open && lapsed && !registered && checkedAt < expiresAt) void check(); - }, [open, lapsed, registered, checkedAt, expiresAt, check]); + if (open && lapsed && !registered && !expired) void check(); + }, [open, lapsed, registered, expired, check]); // The installer's wait for readiness counts from when the node appears. useEffect(() => { if (nodeId) setAppeared((current) => (current?.id === nodeId ? current : { id: nodeId, at: Date.now() })); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 003414a27..492dad044 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -67,7 +67,11 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const [cleanup, setCleanup] = useState<{ node: NodeCleanup; open: boolean } | null>(null); // The enrollment dialog's reads: the node list alone, every few seconds while it waits. It settles // when the read does, which the dialog waits for before calling a command expired. - const refreshNodes = useCallback(() => refetch(), [refetch]); + const { refetch: refetchInventory } = query; + const refreshNodes = useCallback(async () => { + const result = await refetchInventory(); + return result.isSuccess && !result.data.nodesError ? result.data.nodes : null; + }, [refetchInventory]); // Where focus goes once a dialog about a node that is gone closes. const heading = useRef(null); const toast = useToast(); diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index 37895afdd..d95d292cb 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -7,24 +7,13 @@ describe("bundled console capabilities", () => { const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); - expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64), self_hosted_installer: false, self_hosted_installer_sha256: "" }); + expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); expect(fetch).toHaveBeenCalledWith("/console/config", { credentials: "include", signal: controller.signal }); }); it.each([{}, { sandbox_admin: "true", node_installer: true }, { sandbox_admin: false, node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); expect((await sandboxConsoleConfig(new AbortController().signal))?.node_installer).toBe(false); }); - it.each([ - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(64) }, true], - [{ self_hosted_installer: false, self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: "true", self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: true }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "B".repeat(64) }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(63) }, false], - ])("offers the self-hosted installer only when enabled with a verified digest %j", async (body, offered) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.self_hosted_installer).toBe(offered); - }); it("reports unavailable capability on an absent console endpoint", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Not found", { status: 404 }))); expect(await sandboxConsoleConfig(new AbortController().signal)).toBeNull(); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index b90512eb0..ed139bbe5 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -5,9 +5,6 @@ export interface SandboxConsoleConfig { sandbox_admin: boolean; node_installer: boolean; node_installer_sha256: string; - /** The self-hosted executor installer (`/node-install/self-hosted-install.pyz`); false without a verified digest. */ - self_hosted_installer: boolean; - self_hosted_installer_sha256: string; /** * The providers whose node files this console serves. Absent when the console * does not report them (an older console), which blocks nothing; a reported @@ -20,8 +17,7 @@ const SHA256 = /^[a-f0-9]{64}$/; /** * The console's capability flags. Signing in with the Core key grants - * administration, so Core reports only its installers (node and self-hosted - * executor, each with its digest) and the providers it has node files for; + * administration, so Core reports only its node installer and digest and the providers it has node files for; * sandbox administration is available * unless the console says `sandbox_admin: false`. An installer is offered only * with a well-formed SHA-256 digest. @@ -38,8 +34,6 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const digest = "a".repeat(64); @@ -36,13 +36,6 @@ $s python3 "$d/node-install.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c expect(nodeLogCommand("7f3c2a90-fixture", "user")).toBe("journalctl --user -u oac-node-7f3c2a90-fixture.service"); expect(nodeLogCommand("a b", "user")).toBe("journalctl --user -u 'oac-node-a b.service'"); }); - it("creates the exact self-hosted executor install command, every value quoted", () => { - const command = selfHostedInstallCommand({ publicUrl: "https://core.example", digest: "b".repeat(64), environmentId: "env'1", remoteUrl: "wss://core.example/api/v1/agent-daemon/ws" }); - expect(command).toBe(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${"b".repeat(64)}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id 'env'\\''1' --remote 'wss://core.example/api/v1/agent-daemon/ws')`); - }); // "as root": a root shell runs the sudo command without sudo; "no sudo": the no-sudo command. it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no sudo"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => { const parent = join(homedir(), ".oac", "tests"); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 18486ba7c..9123a7647 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -48,19 +48,6 @@ export function nodeUninstallCommand({ sourceUrl, installationId, scriptDigest, return `${nodeInstaller(sourceUrl, scriptDigest, mode)}${runInstaller(mode)} --uninstall --installation-id ${quote(installationId)}${force ? " --force" : ""})`; } -/** - * Installs a self-hosted executor for one Environment from this Core's - * public address. It carries no secret: the installer asks for the executor - * credential at a hidden prompt (or reads `--credential-file`), and rerunning - * it resumes the same installation. - */ -export function selfHostedInstallCommand({ publicUrl, digest, environmentId, remoteUrl }: { publicUrl: string; digest: string; environmentId: string; remoteUrl: string }): string { - return `(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 ${quote(publicUrl + "/node-install/self-hosted-install.pyz")} -o "$d/install.pyz" && -printf '%s %s\\n' ${quote(digest)} "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url ${quote(publicUrl)} --environment-id ${quote(environmentId)} --remote ${quote(remoteUrl)})`; -} - /** * The node service's journal. The installer names the unit after the * installation (node_install.py `unit_name`): a system unit in sudo mode, a user 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 adb03fd2c..cb869e3d4 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -21,7 +21,7 @@ function cache(provider: SandboxDeployment["provider"]) { mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 5930adfc7..9b9d29e24 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -68,20 +68,18 @@ function seconds(value: string | null): number | null { * administrator rotates it (issued, secret lost) or issues it again (not * issued). A kept key ID that is listed is never sent again: its row's actions * own it. Rotating a revoked credential restores it with a new secret, which - * is how a host whose credential was revoked reconnects: its installer accepts - * only the same key ID. An archived project's credentials are listed and + * is how a host whose credential was revoked reconnects: Core retains + * the bound key ID. An archived project's credentials are listed and * revoked but neither issued nor rotated. Below the list, Connect a host gives * the command that installs the executor with one of these credentials. */ -export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string }) { +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl, workspaceDirectory = "" }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { const { t, i18n } = useTranslation("sessions"); const locale = i18n.resolvedLanguage; const toast = useToast(); const { byId, refresh: refreshProjects } = useProjects(); const archived = byId.get(projectId)?.status === "archived"; - const install = useExecutorInstall(environmentId, remoteUrl); - // The one-time dialog repeats the command, so it is copied and run before the credential is pasted. - const command = install.kind === "ready" ? install.command : null; + const install = useExecutorInstall(environmentId, remoteUrl, workspaceDirectory); const query = useQuery(executorConnectionQuery(projectId, sessionId, environmentId)); const credentials = query.data?.data ?? null; const connectionStale = failedLast(query) || query.isStale; @@ -296,7 +294,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId {archived ?

{t("executor.archived")}

: null} {shown && !shown.open && !shown.done ? (
- +
) : null} @@ -309,7 +307,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId onClose={dismissShown} footer={} > - {shown ? : null} + {shown ? : null}

{t(`executor.rotateDialog.${rotation.reason}`, { id: shortId(rotation.keyId) })}

{rotation.reason === "revoked" ? null :

{t("executor.rotateDialog.consequence")}

} - {/* A connected host needs the command again; the text names it only when it is shown here. */} - {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}.${command ? "command" : "installer"}`)}

} + {/* Reconnection uses the installed daemon and the replacement credential file. */} + {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}`)}

} ) : null} diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 344f63bcf..a4a0d56da 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -1,82 +1,37 @@ -import { useQuery } from "@tanstack/react-query"; import { Check, Copy } from "lucide-react"; -import { useEffect, useId, useRef, type RefObject } from "react"; +import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useTranslation } from "react-i18next"; - import { HelpTip } from "../../components/console-ui"; -import { installationQuery } from "../../lib/installation"; +import { ConsoleSelect } from "../../components/console-select"; import { useCopy } from "../api-keys/IssuedKey"; -import { sandboxConsoleConfigQuery } from "../sandbox/sandbox-queries"; -import { executorInstall, type ExecutorInstall } from "./executor-install"; - -const DOCKER_SOCKET = "/var/run/docker.sock"; +import { executorInstall, type ExecutorInstall, type HostShell } from "./executor-install"; -/** The install command's state, including the reads it waits for. */ -export type ExecutorInstallRead = ExecutorInstall | { kind: "loading" } | { kind: "failed"; retry: () => void }; - -/** - * Reads what Connect a host needs: the console's self-hosted installer and, - * only when it is offered, Core's public address. - */ -export function useExecutorInstall(environmentId: string, remoteUrl: string): ExecutorInstallRead { - const config = useQuery(sandboxConsoleConfigQuery); - const offered = config.data?.self_hosted_installer === true; - const installation = useQuery({ ...installationQuery, enabled: offered }); - if (!offered) return { kind: "unavailable" }; - if (installation.data === undefined) { - return installation.isError && !installation.isFetching ? { kind: "failed", retry: () => void installation.refetch() } : { kind: "loading" }; - } - return executorInstall({ config: config.data, publicUrl: installation.data.public_url, localOnly: installation.data.local_only, environmentId, remoteUrl }); +export function useExecutorInstall(environmentId: string, remoteUrl: string, workspaceDirectory: string): ExecutorInstall { + return executorInstall({ environmentId, remoteUrl, workspaceDirectory }); } -/** - * Connect a host: the command that installs this Environment's self-hosted - * executor from Core's public address. It holds no secret; the installer asks - * for a credential from the section above at a hidden prompt. Shown only when - * the console serves the self-hosted installer with a verified digest. - */ -export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstallRead; archived: boolean; connected?: boolean }) { +/** One home for native installation instructions; issuing a credential does not establish a connection. */ +export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstall; archived: boolean; connected?: boolean }) { const { t } = useTranslation("sessions"); - const { t: tCommon } = useTranslation("common"); const headingId = useId(); - if (install.kind === "unavailable") return null; - - let body; - if (install.kind === "failed") { - body =

{t("executor.install.failed")}

; - } else if (install.kind === "loading") { - body =
; - } else { - body = install.kind === "no_address" ?

{t("executor.install.noAddress")}

- : install.kind === "local_only" ?

{t("executor.install.localOnly", { url: install.publicUrl })}

- : install.kind === "not_wss" ?

{t("executor.install.notWss", { remote: install.remoteUrl || "—" })}

- : <> -

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

- -
- {/* The list carries the label for assistive technology; the visible one is not read twice. Safari drops a list's semantics without list-style unless its role is explicit. */} - -
    - {["Linux amd64", "Python 3.9+", "curl", "sha256sum", "Docker CLI", t("executor.install.docker", { socket: DOCKER_SOCKET }), t("executor.install.https", { url: install.publicUrl })].map((item, index) => ( -
  • {index ? : null}{item}
  • - ))} -
-
- ; - } - return ( -
-
-

{t("executor.install.title")}

- {t("executor.install.lifecycle")} - - {connected ? -
- {body} -
- ); + const [shell, setShell] = useState("posix"); + return
+
+

{t("executor.install.title")}

+ {t("executor.install.lifecycle")} + + {connected ? +
+

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

+
{t("executor.install.guide")} + {install.kind === "ready" ? <> + { if (value === "posix" || value === "powershell") setShell(value); }} /> + +

{t("executor.install.start")}

+ :

{t("executor.install.unavailable")}

} +
; } /** Selects a block's text when the clipboard refused it (a plain-HTTP console has none), to copy by hand. */ diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 66d62003f..512f49850 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -118,7 +118,7 @@ export function SessionPage() { const waiting = waitingFor(session); const metadata = Object.entries(session.metadata).filter(([key]) => key !== "title"); const harness = session.agent.x_agents_core?.harness; - const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown }; + const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown; workspace_directory?: unknown }; body = ( {history.gone ?

{t("detail.gone", { time })}

: null} @@ -170,7 +170,7 @@ export function SessionPage() { {environment.type === "self_hosted" && typeof environment.id === "string" ? ( - + ) : null}